From 6e5dc9188dd90c00420fa03e4734690012be72b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=88=E5=85=86=E5=A2=9E?= <你的邮箱@example.com> Date: Wed, 15 Jul 2026 11:13:25 +0800 Subject: [PATCH] =?UTF-8?q?3d=E5=9F=BA=E7=A1=80=E6=A8=A1=E5=9D=97=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=EF=BC=8C=E5=92=8C=E5=9C=B0=E5=9B=BE=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=8A=A0=E6=8E=92=E5=BA=8F=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/docs/地图模块-3D Cesium实施计划.md | 181 ++- frontend/src/components/gis/map.cesium.ts | 1389 ++++++++++++++++--- frontend/src/store/modules/map.ts | 54 +- 3 files changed, 1339 insertions(+), 285 deletions(-) diff --git a/frontend/docs/地图模块-3D Cesium实施计划.md b/frontend/docs/地图模块-3D Cesium实施计划.md index f098dd6b..9b22b1b0 100644 --- a/frontend/docs/地图模块-3D Cesium实施计划.md +++ b/frontend/docs/地图模块-3D Cesium实施计划.md @@ -240,7 +240,7 @@ - 快速切换基地不会出现旧数据覆盖新数据的问题。 - 多次切换 2D/3D 后基地裁切行为正常。 -### Step 5:打通 3D popup 与 hover +### Step 5:打通 3D popup 与 hover ✅️ 目标: @@ -337,7 +337,7 @@ - 图例单项勾选/取消时,3D 对应类别锚点同步显隐。 - 图例分组勾选/取消时,3D 行为与 2D 保持一致。 -### Step 9:补足收口与稳定性 +### Step 9:补足收口与稳定性 ✅️ 目标: @@ -369,95 +369,6 @@ 8. 补图例联动 9. 做切换稳定性收口 -## 9. 必须补齐的隐藏逻辑清单 - -下面这些逻辑不是表面接口能直接看出来的,但如果 `3D` 不补,最终效果就会和 `2D` 明显不一致。 - -### 9.1 图层与菜单切换 - -- 菜单切换时,只隐藏旧页点图层,不隐藏底图。 -- 图层树勾选前要先经过互斥规则归一化: - - `rare_fish_point` 与 `fish_along_point` 互斥 - - `stinfo/stinfo_video_point` 与 `stinfo_ai_video_point` 互斥 - - `facilities` 与 `facilities_built` 两个分支互斥 -- 点图层取消勾选时只隐藏,不删除缓存;重新勾选时优先从缓存恢复。 -- **图层树勾选时按 `type` 字段走不同分支**(见 `updateLayerData`): - - `type === 'pointMap'` → 走 `handlePointMapLayer` → 调用 `mdLayerTreeShowOrHidden`(控制锚点显隐) - - `type === 'GISMap'` → 走 `controlBaseLayerTreeShowAndHidden`(控制底图/叠加影像图层显隐) - - 3D Cesium 必须同时实现这两个方法才能与图层树完整联动。 - -### 9.2 图例与点内显隐 - -- 图例控制的是图层内部锚点是否可见,不是图层本身是否可见。 -- 图层恢复显示时,图例要先整层全关,再按当前 checked 状态逐项恢复。 -- 环保设施图例组当前是只展示不交互。 - -### 9.3 缩放驱动显示 - -- `eng_point` 的中型水电站只在 `ENG_POINT_MEDIUM_VISIBLE_ZOOM = 7.5` 以上显示。 -- 水电开发页缩放跨过 `12` 级时,会自动补开一批动态图层;缩回去时再自动收口。 -- `2D` 存在"缩放越大显示越多"的行为,不是所有锚点在所有层级都等量显示。 -- `2D` 还存在 hover、批量 popup、密度控制等缩放阈值: - - hover 低于一定缩放不触发 - - 高缩放会进入批量 popup 模式 - - 点位会随着缩放提升逐步放开显示限制 -- **图标动态缩放**:`scale = 0.7 + (zoom - 4.5) * 0.08`,钳位到 `[0.5, 3.0]`。 -- **字体动态大小**:`fontSize = clamp(12 * scale, 10, 24)`,即 10px~24px 之间随缩放变化。 -- **文字在图标上方**:2D 中 `text.offsetY = -22 * scale`,负值表示文字偏移到图标上方。 -- **注意**:上述 2D scale 公式是针对 OpenLayers 2D 投影校准的,Cesium 3D 球面视角需要更高的基准值(建议 1.0 起步),且 `scaleByDistance` 在 9,000km 中国高度处不应过度压缩(建议 near/far 范围设为 1km→2x, 20,000km→0.6x)。 -- **Cesium 中必须使用 `CallbackProperty` 实现动态缩放**:3D 中 `billboard.scale` 不能是静态值,否则放大后图标不会跟随变大。使用 `CallbackProperty` 每帧重新计算当前 zoom 对应的 scale,与 2D `getDynamicPointScale` 行为对齐。 - -### 9.4 锚点碰撞与控量 - -- `2D` 中锚点是否可见,不只受图层开关控制,还受以下门闩共同影响: - - 图例可见性 - - 区域裁切可见性 - - 密度阈值 - - 图标碰撞结果 - - 标签碰撞结果 -- 低缩放下,为避免堆叠,部分锚点或标签会被主动隐藏。 -- 高缩放下,会逐步放开图标/标签限制,显示更多内容。 -- 因此 `3D` 不能简单把所有 entity 永久全开,否则视觉密度会明显失控。 - -### 9.5 Popup 与点击 - -- hover popup 不是全时开启的,有缩放门槛。 -- 点击和 hover 的命中对象是图标本体,不是任意文字区域。 -- `ylfb` 与普通锚点的点击详情弹框走不同业务分支。 - -### 9.6 基地与区域裁切 - -- 基地切换不仅影响搜索候选,还会影响地图上的点位可见范围。 -- 区域裁切会直接把区域外点位隐藏,不是只改底图遮罩。 - -### 9.7 筛选器特殊逻辑 - -- 装机容量筛选本质是批量切 `eng_point` 图例状态,不是简单切图层。 -- 鱼类分布模式会接管 `ylfb_point` 的数据、缓存、图例与显隐。 -- 时间筛选当前只重载指定时间相关图层,不是全量重载。 - -### 9.8 数据进入 addInitDataLayer 前的预处理 - -- 所有点位数据在进入 `addInitDataLayer` 前会经过 `normalizePointLayerItems()` 过滤: - - 没有 `iconCode` 且 `code !== 'colorLayer'` 的项会被抛弃 - - `baseId == 'all'` 且 `anchoPointState` 结尾为 `_nbuilt` 的项会被抛弃 -- `_id` 默认值生成规则:如果项没有 `_id`,会生成 `${sttpMap || iconType || 'point'}_${stcd}` -- **stcd+经纬度重复锚点处理**:同一个 layerKey 下,相同 stcd 和经纬度但不同 sttpMap/anchoPointState 的锚点需要全部保留,不能因为 Feature/Entity ID 冲突而被覆盖。2D 使用 `${layerKey}:${_id}` 作为 ID,其中 `_id` 默认为 `${sttpMap}_${stcd}`,天然区分了同 stcd 不同业务类型的点。 - -### 9.9 图标渐进加载机制 - -- 2D 中图标不是一次性全加载完。有 `loading → loaded → error` 三种状态跟踪。 -- 只有 `loaded` 状态的图标才参与样式渲染,loader 失败的不渲染。 -- 如果在图标加载过程中就触发了样式刷新,需要通过 `requestAnimationFrame` 回刷。 -- 3D Cesium 中如果要实际渲染图标纹理,同样需要这种渐进加载机制,或在实现时先以简单 billboard 色块兜底。 - -### 9.10 密度分档与近邻点元数据 - -- 每个锚点的 `distance` 字段表示密度分档值(不是物理距离),值越大表示越稀疏。 -- 不同 density 值对应不同的 `minZoom` 显示门槛(见 `nearby-point-rules.ts` 的 `densityDisplayRules`)。 -- 全量数据加载后,store 会调用 `attachNearbyPointRuntimeMeta()` 给所有点打上 `_nearbyRule`、`_nearbyGroupId`、`_nearbyPriority` 等元数据。 -- **孤立点例外规则**:如果一个点在当前视口内 ~56px 范围内没有同 layerKey 的其他可见点,它可以绕过密度门槛直接显示。 - ## 7. 预计修改文件 - `src/components/gis/map.cesium.ts` @@ -478,3 +389,91 @@ - 默认飞到中国 这一步最小、最容易验证,也能先把“打开 3D 看起来不对”的核心问题解决掉。 + +## 9. 必须补齐的隐藏逻辑清单 + +下面这些逻辑不是表面接口能直接看出来的,但如果 `3D` 不补,最终效果就会和 `2D` 明显不一致。 + +### 9.1 图层与菜单切换 ✅️ + +- ✅ 菜单切换时,只隐藏旧页点图层,不隐藏底图。(编排器 `handlePageChange` 控制,Cesium 被动接收数据) +- ✅ 图层树勾选前要先经过互斥规则归一化:(store 层 `updateLayerData` 处理,Cesium 不感知) + - `rare_fish_point` 与 `fish_along_point` 互斥 + - `stinfo/stinfo_video_point` 与 `stinfo_ai_video_point` 互斥 + - `facilities` 与 `facilities_built` 两个分支互斥 +- ✅ 点图层取消勾选时只隐藏,不删除缓存;重新勾选时优先从缓存恢复。`mdLayerTreeShowOrHidden` 设置 `_layerVisible` 标志,不删除 `pointLayerRegistry` 条目。 +- ✅ **图层树勾选时按 `type` 字段走不同分支**(见 `updateLayerData`,store 层处理): + - `type === 'pointMap'` → 走 `handlePointMapLayer` → 调用 `mdLayerTreeShowOrHidden`(已实现,L1624) + - `type === 'GISMap'` → 走 `controlBaseLayerTreeShowAndHidden`(已实现,L1345) + - 3D Cesium 已同时实现这两个方法。 + +### 9.2 图例与点内显隐 ✅️ + +- ✅ 图例控制的是图层内部锚点是否可见,不是图层本身是否可见。`setLegendPointVisible` 使用 `_legendVisible` 标志,与 `_layerVisible` 独立。 +- ✅ 图层恢复显示时,图例要先整层全关,再按当前 checked 状态逐项恢复。支持 `anchoPointState=''` 全关 → 逐项 `(state, true)` 恢复两步模式(L1764-1787)。 +- ✅ 环保设施图例组当前是只展示不交互。(编排器层面控制,Cesium 不感知) + +### 9.3 缩放驱动显示 ✅️ + +- ✅ `eng_point` 的中型水电站只在 `ENG_POINT_MEDIUM_VISIBLE_ZOOM = 7.5` 以上显示。(store `filterPointLayerDataForDisplay` 在数据进入 Cesium 前过滤) +- ✅ 水电开发页缩放跨过 `12` 级时,会自动补开一批动态图层;缩回去时再自动收口。(编排器层面处理) +- ✅ `2D` 存在"缩放越大显示越多"的行为。碰撞检测中密度门控逐级放开。 +- ✅ `2D` 还存在 hover、批量 popup、密度控制等缩放阈值: + - hover 低于 ~zoom 4.5 不触发(`HOVER_POPUP_MAX_HEIGHT = 9_000_000`) + - 高缩放进入批量 popup 模式(`BATCH_POPUP_MODE_ZOOM = 14.9`) + - 点位随着缩放提升逐步放开密度限制(密度门控 + `DENSITY_ISOLATION_THRESHOLD`) +- ✅ **图标动态缩放**:`CallbackProperty` 实现 `billboard.scale`,公式 `clamp(0.7 + (zoom - 4.5) * 0.035, 0.4, 1.2)`(L1522)。 +- ✅ **字体动态大小**:`CallbackProperty` 实现 `label.font`,钳位 `[10, 20]`(L1535)。 +- ✅ **文字在图标上方**:`CallbackProperty` 实现 `label.pixelOffset`,负 Y 偏移(L1549)。 +- ✅ **Cesium 中必须使用 `CallbackProperty` 实现动态缩放**:已使用 3 个 `CallbackProperty`(scale/font/offset),`isConstant=false` 每帧重算。 + +### 9.4 锚点碰撞与控量 ✅️ + +- ✅ `2D` 中锚点是否可见,不只受图层开关控制,还受以下门闩共同影响: + - 图例可见性 `_legendVisible` + - 区域裁切可见性 `_regionVisible` + - 密度阈值(碰撞循环中 inline 计算,逐级放开) + - 图标碰撞结果 `_iconCollisionVisible` + - 标签碰撞结果 `_labelCollisionVisible` +- ✅ 低缩放下,为避免堆叠,部分锚点或标签会被主动隐藏。(`refreshPointLabelVisibility` 碰撞检测,L2117-2455) +- ✅ 高缩放下,会逐步放开图标/标签限制,显示更多内容。(密度门控 + 碰撞容差随 zoom 调整) +- ✅ 因此 `3D` 不能简单把所有 entity 永久全开。所有 entity 初始 `_iconCollisionVisible = _labelCollisionVisible = true`,碰撞检测运行后动态调整。 + +### 9.5 Popup 与点击 ✅️ + +- ✅ hover popup 不是全时开启的,有缩放门槛。相机高度 > `HOVER_POPUP_MAX_HEIGHT` 时不触发 hover。 +- ✅ 点击和 hover 的命中对象是图标本体,不是任意文字区域。`ScreenSpaceEventHandler` 基于 `scene.pick` 命中 entity。 +- ✅ `ylfb` 与普通锚点的点击详情弹框走不同业务分支。`sttpMap === 'ylfb'` 打开 `ylfbModalVisible`,其他走 `modalVisible`(L366-375)。 + +### 9.6 基地与区域裁切 ✅️ + +- ✅ 基地切换不仅影响搜索候选,还会影响地图上的点位可见范围。`jdPanelControlShowAndHidden` 实现(L1684-1748)。 +- ✅ 区域裁切会直接把区域外点位隐藏,不是只改底图遮罩。`filterPointsByRegion` 设置 `_regionVisible = false`(L1824-1848),遮罩多边形 + 裁剪双重处理。 + +### 9.7 筛选器特殊逻辑 ✅️ + +- ✅ 装机容量筛选本质是批量切 `eng_point` 图例状态,不是简单切图层。(store 层处理,Cesium 通过 `setLegendPointVisible` 响应) +- ✅ 鱼类分布模式会接管 `ylfb_point` 的数据、缓存、图例与显隐。(store 层处理) +- ✅ 时间筛选当前只重载指定时间相关图层,不是全量重载。(store 层处理) + +### 9.8 数据进入 addInitDataLayer 前的预处理 ✅️ + +- ✅ 所有点位数据在进入 `addInitDataLayer` 前会经过 `normalizePointLayerItems()` 过滤:(store 层 `map.ts` 执行,Cesium 接收时已过滤) + - 没有 `iconCode` 且 `code !== 'colorLayer'` 的项会被抛弃 + - `baseId == 'all'` 且 `anchoPointState` 结尾为 `_nbuilt` 的项会被抛弃 +- ✅ `_id` 默认值生成规则:store 层生成 `${sttpMap || iconType || 'point'}_${stcd}`。 +- ✅ **stcd+经纬度重复锚点处理**:Cesium entity ID 使用 `${layerKey}:${item._id}`,与 2D 对齐,同 stcd 不同 sttpMap 的点不会互相覆盖。 + +### 9.9 图标渐进加载机制 + +- ❌ 2D 中图标不是一次性全加载完。有 `loading → loaded → error` 三种状态跟踪。 +- ❌ 只有 `loaded` 状态的图标才参与样式渲染,loader 失败的不渲染。 +- ❌ 如果在图标加载过程中就触发了样式刷新,需要通过 `requestAnimationFrame` 回刷。 +- ❌ 3D Cesium 中当前所有 entity 同步创建,billboard `image` URL 直接赋值,无渐进加载。Cesium 内部纹理加载无显式状态跟踪。 + +### 9.10 密度分档与近邻点元数据 ✅️ + +- ✅ 每个锚点的 `distance` 字段表示密度分档值(不是物理距离),值越大表示越稀疏。(已使用 `getEntityDensityValue` 从 `_rawData.distance` 读取,L2460) +- ✅ 不同 density 值对应不同的 `minZoom` 显示门槛。`getEntityDensityMinZoom` 映射 `nearby-point-rules.ts` 的 `densityDisplayRules`(L2491)。 +- ✅ 全量数据加载后,store 会调用 `attachNearbyPointRuntimeMeta()` 给所有点打上 `_nearbyRule`、`_nearbyGroupId`、`_nearbyPriority` 等元数据。(store 层处理,Cesium 碰撞检测中读取) +- ✅ **孤立点例外规则**:如果一个点在当前视口内 ~56px 范围内没有同 layerKey 的其他可见点,它可以绕过密度门槛直接显示。`DENSITY_ISOLATION_THRESHOLD = 56` + `shouldBypassDensityGateForIsolatedEntity`(L2506-2556)。 diff --git a/frontend/src/components/gis/map.cesium.ts b/frontend/src/components/gis/map.cesium.ts index 84e29dd4..afcf34a7 100644 --- a/frontend/src/components/gis/map.cesium.ts +++ b/frontend/src/components/gis/map.cesium.ts @@ -4,9 +4,11 @@ import type { MapInterface, layer } from './map.d'; import { getIconPath } from '@/utils/index'; import { generatePopupHtml, - shouldPreferEng2Popup + shouldPreferEng2Popup, + getNormalizedPopupSttpMap } from '@/utils/popupHtmlGenerator'; import { useModelStore } from '@/store/modules/model'; +import { getNearbyPointDensityDisplayRules } from '@/modules/map/domain/nearby-point-rules'; export class MapCesium implements MapInterface { private viewer: Cesium.Viewer | null = null; @@ -15,11 +17,14 @@ export class MapCesium implements MapInterface { private hoveredEntityId: string | null = null; private hoverRafId: number | null = null; // requestAnimationFrame 节流 private removePostRenderListener: (() => void) | null = null; + private removeCameraChangedListener: (() => void) | null = null; private isBatchPopupMode = false; private batchPopupContainer: HTMLDivElement | null = null; private batchPopupItems: Array<{ entity: Cesium.Entity; element: HTMLDivElement; + popupWidth: number; + popupHeight: number; }> = []; private batchPostRenderRemover: (() => void) | null = null; private containerId = ''; @@ -63,7 +68,14 @@ export class MapCesium implements MapInterface { private readonly CESIUM_ZOOM_FORMULA_C = 91610.74; private readonly CESIUM_ZOOM_FORMULA_D = -40467.74; private readonly HOVER_POPUP_MAX_HEIGHT = 9_000_000; // ~zoom 4.5,中国视角以上不触发 hover - private readonly BATCH_POPUP_MODE_ZOOM = 15; // zoom >= 15 进入批量固定模式 + private readonly BATCH_POPUP_MODE_ZOOM = 14.9; // zoom >= 14.9 进入批量固定模式(float 容差避免 15 刚好踩边界) + + // ==================== Step 6: 碰撞检测 ==================== + private collisionRefreshFrameId: number | null = null; + private labelVisibilityDebounceTimerId: number | null = null; + private readonly COLLISION_GRID_CELL_SIZE = 96; // 与 2D 一致 + private readonly DENSITY_ISOLATION_THRESHOLD = 56; // 孤立点 pixel 阈值 + private readonly COLLISION_LABEL_PADDING = 4; // 标签碰撞 padding // 图标缩放可调参数(只影响 3D Cesium,不参与 2D 计算) // scale = base + (zoom - 4.5) * rate,钳位到 [min, max] @@ -107,18 +119,26 @@ export class MapCesium implements MapInterface { terrainProvider: new Cesium.EllipsoidTerrainProvider() }); + this.viewer.cesiumWidget.creditContainer.style.display = 'none'; const layers = this.viewer.imageryLayers; layers.removeAll(); const arcgisUrl = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/'; - const provider = await Cesium.ArcGisMapServerImageryProvider.fromUrl( - arcgisUrl, - { - enablePickFeatures: false, - maximumLevel: 19 - } - ); + // const provider = await Cesium.ArcGisMapServerImageryProvider.fromUrl( + // arcgisUrl, + // { + // enablePickFeatures: false, + // maximumLevel: 19 + // } + // ); + const provider = new Cesium.UrlTemplateImageryProvider({ + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + maximumLevel: 19, + credit: + 'Esri, DigitalGlobe, GeoEye, i-cubed, USDA FSA, USGS, AEX, Getmapping, Aerogrid, IGN, IGP, swisstopo, and the GIS User Community' + }); + this.imageryBaseLayer = layers.addImageryProvider(provider); this.imageryBaseLayer.show = true; @@ -133,6 +153,7 @@ export class MapCesium implements MapInterface { this.setupClickHandler(); this.setupCameraPopupReset(); + console.log('[Cesium] init complete, viewer ready'); return this.viewer; } catch (error) { this.hideLoadingOverlay(); @@ -367,6 +388,16 @@ export class MapCesium implements MapInterface { this.removePostRenderListener(); this.removePostRenderListener = null; } + if (this.removeCameraChangedListener) { + this.removeCameraChangedListener(); + this.removeCameraChangedListener = null; + } + + // camera.changed:相机运动中实时刷新碰撞检测(debounce 150ms) + this.removeCameraChangedListener = + this.viewer.camera.changed.addEventListener(() => { + this.requestRefreshPointLabelVisibility(false, 150); + }); this.removePostRenderListener = this.viewer.camera.moveEnd.addEventListener( () => { @@ -377,9 +408,12 @@ export class MapCesium implements MapInterface { if (zoom >= this.BATCH_POPUP_MODE_ZOOM) { if (!this.isBatchPopupMode) { this.enableBatchPopupMode(); + } else { + // 已处于批量模式:重建 popup(与 2D moveend 行为对齐) + // 拖拽/平移后视口内可能出现新的可见锚点,仅 postRender 重定位无法创建新 popup + // 同时修复 zoom 进出循环后 popup 消失的问题 + this.rebuildBatchPopupsIfNeeded(); } - // 已处于批量模式时不做重建(postRender 已持续更新位置), - // 避免 clear+rebuild 产生空白帧闪烁 } else { if (this.isBatchPopupMode) { this.disableBatchPopupMode(); @@ -389,6 +423,9 @@ export class MapCesium implements MapInterface { this.repositionHoverPopup(); } } + + // Step 6: 碰撞检测 — 相机停止移动后重新计算图标/标签可见性 + this.requestRefreshPointLabelVisibility(); } ); } @@ -452,6 +489,19 @@ export class MapCesium implements MapInterface { return zoom > -1 ? zoom : 0; } + /** 判断实体是否属于 ENG 类型(用于 popup 碰撞优先保留,与 isEntityEng 对齐) */ + private isEngEntity(entity: Cesium.Entity): boolean { + const e = entity as any; + // 优先从实体 properties._sttpMap 直接检查(与 Entity 创建时设置的 _sttpMap 一致) + const sttpFromProps = String(e._sttpMap || '').toUpperCase(); + if (sttpFromProps.startsWith('ENG')) return true; + // 其次从 _rawData 检查 + const rawData = e._rawData as Record | undefined; + if (!rawData) return false; + const sttpMap = getNormalizedPopupSttpMap(rawData).toUpperCase(); + return sttpMap.startsWith('ENG'); + } + /** 判断实体当前是否可交互(图层可见 + 图例可见 + 区域可见) */ private isEntityInteractive(entity: Cesium.Entity): boolean { const e = entity as any; @@ -521,23 +571,237 @@ export class MapCesium implements MapInterface { /** 启用批量 popup:所有可见锚点固定显示 popup */ private enableBatchPopupMode() { + console.log('[Cesium] enableBatchPopupMode'); this.isBatchPopupMode = true; this.clearBatchPopups(); - this.showBatchPopups(); - this.setupBatchPopupPostRenderSync(); + this.hidePopup(); // 清除可能残留的 hover popup + // 先同步执行碰撞检测,确保 _iconCollisionVisible 已正确计算, + // 避免重叠锚点的 popup 在 showBatchPopups 中被短暂创建 + this.refreshPointLabelVisibility(); + // 等所有 chunk 处理完再注册 postRender,避免用不完整列表做碰撞 + this.showBatchPopups().then(() => { + if (this.isBatchPopupMode) { + this.setupBatchPopupPostRenderSync(); + } + }); } /** 退出批量 popup */ private disableBatchPopupMode() { + console.log('[Cesium] disableBatchPopupMode'); this.isBatchPopupMode = false; this.clearBatchPopupPostRenderSync(); this.clearBatchPopups(); this.hidePopup(); } - /** 遍历所有图层中交互可见且视口内的实体,分帧创建批量 popup(避免卡顿) */ - private showBatchPopups() { - if (!this.viewer || !this.containerElement) return; + /** 收集视口内可交互实体并按 X 坐标排序(ENG 优先) */ + private collectBatchPopupCandidates(): Array<{ + entity: Cesium.Entity; + x: number; + y: number; + popupHtml: string; + }> { + if (!this.viewer) return []; + + const canvas = this.viewer.scene.canvas; + const viewW = canvas.clientWidth; + const viewH = canvas.clientHeight; + const padding = 60; + + const candidates: Array<{ + entity: Cesium.Entity; + x: number; + y: number; + popupHtml: string; + }> = []; + + this.pointLayerRegistry.forEach(entities => { + entities.forEach(entity => { + if (!this.isEntityInteractive(entity)) return; + // 图标被碰撞/密度隐藏的锚点不展示 popup(没有可见图标,popup 无意义且干扰碰撞) + if ((entity as any)._iconCollisionVisible === false) return; + const position = entity.position?.getValue( + this.viewer!.clock.currentTime + ); + if (!position) return; + + const cp = Cesium.SceneTransforms.worldToWindowCoordinates( + this.viewer!.scene, + position + ); + if (!cp) return; + + if ( + cp.x < -padding || + cp.y < -padding || + cp.x > viewW + padding || + cp.y > viewH + padding + ) + return; + + const rawData: Record = { + ...((entity as any)._rawData || {}) + }; + if (!rawData.sttpMap) { + rawData.sttpMap = rawData._sttpMap || rawData.popupSttpMap || ''; + } + + let popupHtml = shouldPreferEng2Popup(rawData) + ? generatePopupHtml(rawData, { forceEng2: true }) || + rawData.popupHtml || + generatePopupHtml(rawData) + : this.getPopupHtml(rawData); + + if (!popupHtml) { + const title = rawData.titleName || rawData.stnm || rawData.ennm || ''; + if (!title) return; + popupHtml = `
${title}
`; + } + + candidates.push({ entity, x: cp.x, y: cp.y, popupHtml }); + }); + }); + + // 按 X 坐标排序,X 接近时(5px 容差,3D 投影浮点误差)ENG 优先 + candidates.sort((a, b) => { + const dx = a.x - b.x; + if (Math.abs(dx) > 5) return dx; + const aEng = this.isEngEntity(a.entity) ? 0 : 1; + const bEng = this.isEngEntity(b.entity) ? 0 : 1; + if (aEng !== bEng) return aEng - bEng; + return dx; + }); + + return candidates; + } + + /** 在指定容器中分帧构建 popup DOM(避免卡顿),完成后 resolve */ + private buildBatchPopupsInContainer( + container: HTMLDivElement, + candidates: Array<{ + entity: Cesium.Entity; + x: number; + y: number; + popupHtml: string; + }> + ): Promise { + return new Promise(resolve => { + let index = 0; + const CHUNK_SIZE = 80; + const placedPopups: Array<{ + left: number; + right: number; + top: number; + bottom: number; + entity: Cesium.Entity; + element: HTMLDivElement; + }> = []; + + const processChunk = () => { + if (!this.isBatchPopupMode) return; + + const end = Math.min(index + CHUNK_SIZE, candidates.length); + for (let i = index; i < end; i++) { + const { entity, x, y, popupHtml } = candidates[i]; + + const popupEl = document.createElement('div'); + popupEl.className = + this.popupElement?.className || 'map-popup-container'; + popupEl.innerHTML = popupHtml; + popupEl.style.position = 'absolute'; + popupEl.style.display = 'block'; + popupEl.style.transform = 'translate(-50%, calc(-100% - 10px))'; + popupEl.style.pointerEvents = 'none'; + + // 先隐藏测量尺寸 + popupEl.style.visibility = 'hidden'; + popupEl.style.left = `${x}px`; + popupEl.style.top = `${y}px`; + container.appendChild(popupEl); + + const rect = popupEl.getBoundingClientRect(); + const pw = rect.width || 120; + const ph = rect.height || 40; + + const popupRect = { + left: x - pw / 2, + right: x + pw / 2, + top: y - 10 - ph, + bottom: y - 10 + }; + + const isCurrentEng = this.isEngEntity(entity); + let collidedIdx = -1; + + for (let k = 0; k < placedPopups.length; k++) { + if (this.checkPopupCollision(popupRect, placedPopups[k])) { + collidedIdx = k; + break; + } + } + + if (collidedIdx === -1) { + // 无碰撞:正常放置 + popupEl.style.visibility = 'visible'; + popupEl.style.left = `${x}px`; + popupEl.style.top = `${y}px`; + this.batchPopupItems.push({ + entity, + element: popupEl, + popupWidth: pw, + popupHeight: ph + }); + placedPopups.push({ ...popupRect, entity, element: popupEl }); + } else { + const collided = placedPopups[collidedIdx]; + const isCollidedEng = this.isEngEntity(collided.entity); + + if (isCurrentEng && !isCollidedEng) { + // ENG 碰撞普通 popup:移除普通,展示 ENG + collided.element.remove(); + this.batchPopupItems = this.batchPopupItems.filter( + item => item.element !== collided.element + ); + popupEl.style.visibility = 'visible'; + popupEl.style.left = `${x}px`; + popupEl.style.top = `${y}px`; + this.batchPopupItems.push({ + entity, + element: popupEl, + popupWidth: pw, + popupHeight: ph + }); + placedPopups[collidedIdx] = { + ...popupRect, + entity, + element: popupEl + }; + } else if (!isCurrentEng && isCollidedEng) { + // 普通碰撞 ENG:隐藏当前普通 popup,保留 ENG + container.removeChild(popupEl); + } else { + // 同类型碰撞:后来的让路 + container.removeChild(popupEl); + } + } + } + + index = end; + if (index < candidates.length && this.isBatchPopupMode) { + requestAnimationFrame(processChunk); + } else { + resolve(); + } + }; + + requestAnimationFrame(processChunk); + }); + } + + /** 创建批量 popup 容器并构建所有可见 popup */ + private showBatchPopups(): Promise { + if (!this.viewer || !this.containerElement) return Promise.resolve(); const batchContainer = document.createElement('div'); batchContainer.className = 'batch-popup-container'; @@ -554,133 +818,14 @@ export class MapCesium implements MapInterface { this.batchPopupContainer = batchContainer; this.containerElement.appendChild(batchContainer); - const canvas = this.viewer.scene.canvas; - const viewW = canvas.clientWidth; - const viewH = canvas.clientHeight; - const padding = 60; - - // 收集所有需要渲染的实体信息(含视口裁剪) - const candidates: Array<{ - entity: Cesium.Entity; - x: number; - y: number; - popupHtml: string; - }> = []; - - this.pointLayerRegistry.forEach(entities => { - entities.forEach(entity => { - if (!this.isEntityInteractive(entity)) return; - const position = entity.position?.getValue( - this.viewer!.clock.currentTime - ); - if (!position) return; - - const cp = Cesium.SceneTransforms.worldToWindowCoordinates( - this.viewer!.scene, - position - ); - if (!cp) return; - - // 视口裁剪:超出屏幕的直接跳过 - if ( - cp.x < -padding || - cp.y < -padding || - cp.x > viewW + padding || - cp.y > viewH + padding - ) - return; - - const rawData: Record = { - ...((entity as any)._rawData || {}) - }; - if (!rawData.sttpMap) { - rawData.sttpMap = rawData._sttpMap || rawData.popupSttpMap || ''; - } - - // 批量模式:eng 锚点优先用 eng2(详细数据),与 2D 对齐 - const popupHtml = shouldPreferEng2Popup(rawData) - ? generatePopupHtml(rawData, { forceEng2: true }) || - rawData.popupHtml || - generatePopupHtml(rawData) - : this.getPopupHtml(rawData); - if (!popupHtml) return; - - candidates.push({ entity, x: cp.x, y: cp.y, popupHtml }); - }); - }); - - // 分帧渲染,每帧最多 80 个,避免一次性 DOM 操作卡顿 - let index = 0; - const CHUNK_SIZE = 80; - const placedPopups: Array<{ - left: number; - right: number; - top: number; - bottom: number; - }> = []; - - const processChunk = () => { - if (!this.isBatchPopupMode || !this.batchPopupContainer) return; - - const end = Math.min(index + CHUNK_SIZE, candidates.length); - for (let i = index; i < end; i++) { - const { entity, x, y, popupHtml } = candidates[i]; - - const popupEl = document.createElement('div'); - popupEl.className = - this.popupElement?.className || 'map-popup-container'; - popupEl.innerHTML = popupHtml; - popupEl.style.position = 'absolute'; - popupEl.style.display = 'block'; - popupEl.style.transform = 'translate(-50%, calc(-100% - 10px))'; - popupEl.style.pointerEvents = 'none'; - - // 先隐藏测量尺寸 - popupEl.style.visibility = 'hidden'; - popupEl.style.left = `${x}px`; - popupEl.style.top = `${y}px`; - this.batchPopupContainer!.appendChild(popupEl); - - const rect = popupEl.getBoundingClientRect(); - const pw = rect.width || 120; - const ph = rect.height || 40; - - const popupRect = { - left: x - pw / 2, - right: x + pw / 2, - top: y - 10 - ph, - bottom: y - 10 - }; - - let hasCollision = false; - for (const placed of placedPopups) { - if (this.checkPopupCollision(popupRect, placed)) { - hasCollision = true; - break; - } - } - - if (!hasCollision) { - popupEl.style.visibility = 'visible'; - popupEl.style.left = `${x}px`; - popupEl.style.top = `${y}px`; - this.batchPopupItems.push({ entity, element: popupEl }); - placedPopups.push(popupRect); - } else { - this.batchPopupContainer!.removeChild(popupEl); - } - } - - index = end; - if (index < candidates.length && this.isBatchPopupMode) { - requestAnimationFrame(processChunk); - } - }; - - requestAnimationFrame(processChunk); + const candidates = this.collectBatchPopupCandidates(); + return this.buildBatchPopupsInContainer(batchContainer, candidates); } - /** 每帧更新批量 popup 屏幕位置(不重建 DOM,拖拽时流畅跟随) */ + /** 每一帧都在更新,不需要持久化,但清理时一并重置 */ + private popupCollisionFrames = new WeakMap(); + + /** 每帧更新批量 popup 屏幕位置 + 碰撞检测(拖拽/缩放时流畅跟随) */ private updateBatchPopupPositions() { if (!this.viewer || !this.batchPopupContainer) return; if (this.batchPopupItems.length === 0) return; @@ -690,14 +835,35 @@ export class MapCesium implements MapInterface { const viewH = canvas.clientHeight; const padding = 48; + // 第一遍:更新位置、收集可见项的屏幕矩形(用缓存宽高,不依赖 DOM 测量) + const visibleRects: { + item: (typeof this.batchPopupItems)[number]; + x: number; + y: number; + left: number; + right: number; + top: number; + bottom: number; + }[] = []; + for (let i = 0; i < this.batchPopupItems.length; i++) { - const { entity, element } = this.batchPopupItems[i]; + const item = this.batchPopupItems[i]; + const { entity, element, popupWidth, popupHeight } = item; + + // 先恢复为 block,碰撞检测再做最终决定 + element.style.display = 'block'; if (!this.isEntityInteractive(entity)) { element.style.display = 'none'; continue; } + // 图标被碰撞隐藏的锚点不展示 popup + if ((entity as any)._iconCollisionVisible === false) { + element.style.display = 'none'; + continue; + } + const position = entity.position?.getValue( this.viewer!.clock.currentTime ); @@ -725,15 +891,142 @@ export class MapCesium implements MapInterface { continue; } - element.style.left = `${cp.x}px`; - element.style.top = `${cp.y}px`; - element.style.display = 'block'; + const x = cp.x; + const y = cp.y; + element.style.left = `${x}px`; + element.style.top = `${y}px`; + + visibleRects.push({ + item, + x, + y, + left: x - popupWidth / 2, + right: x + popupWidth / 2, + top: y - 10 - popupHeight, + bottom: y - 10 + }); } + + // 第二遍:碰撞检测 — 按 X 排序后右让左,X 接近时(5px 容差)ENG 优先 + visibleRects.sort((a, b) => { + const dx = a.x - b.x; + if (Math.abs(dx) > 5) return dx; + const aEng = this.isEngEntity(a.item.entity) ? 0 : 1; + const bEng = this.isEngEntity(b.item.entity) ? 0 : 1; + if (aEng !== bEng) return aEng - bEng; + return dx; + }); + + const collisionFlags = new Array(visibleRects.length).fill(false); + const instantFlags = new Array(visibleRects.length).fill(false); // ENG 碰撞立即隐藏 + for (let i = 1; i < visibleRects.length; i++) { + const a = visibleRects[i]; + for (let j = 0; j < i; j++) { + if (collisionFlags[j]) continue; + const b = visibleRects[j]; + + if (!this.checkPopupCollision(a, b)) continue; + + const aEng = this.isEngEntity(a.item.entity); + const bEng = this.isEngEntity(b.item.entity); + + if (aEng && !bEng) { + // ENG (a) 碰撞普通 (b):立即隐藏普通,ENG 继续检查是否碰撞其他已放置项 + collisionFlags[j] = true; + instantFlags[j] = true; + } else if (!aEng && bEng) { + // 普通 (a) 碰撞 ENG (b):立即隐藏普通 + collisionFlags[i] = true; + instantFlags[i] = true; + break; + } else { + // 同类型碰撞:后来的让路(经 2 帧滞后防闪烁) + collisionFlags[i] = true; + break; + } + } + } + + // 第三遍:应用显示状态,ENG 碰撞立即隐藏,同类型 2 帧滞后防闪烁 + for (let i = 0; i < visibleRects.length; i++) { + const { element } = visibleRects[i].item; + const hidden = collisionFlags[i]; + + if (hidden && instantFlags[i]) { + // ENG 碰撞:立即隐藏,不经过滞后 + element.style.display = 'none'; + this.popupCollisionFrames.delete(element); + continue; + } + + const prevFrames = this.popupCollisionFrames.get(element) || 0; + if (hidden) { + const count = prevFrames > 0 ? prevFrames + 1 : 1; + this.popupCollisionFrames.set(element, count); + if (count >= 2) { + element.style.display = 'none'; + } + } else { + const count = prevFrames < 0 ? prevFrames - 1 : -1; + this.popupCollisionFrames.set(element, count); + if (count <= -2) { + element.style.display = 'block'; + } + } + } + } + + /** 批量模式下双缓冲重建 popup(图层/图例变化、拖拽/缩放 moveEnd 时调用)。 + * 在新隐藏容器中构建 popup,构建完成后替换旧容器,避免 clear→rebuild 空白帧闪烁。 */ + private _rebuildGeneration = 0; + + private rebuildBatchPopupsIfNeeded() { + if (!this.isBatchPopupMode || !this.containerElement) return; + + console.log( + '[Cesium] rebuildBatchPopups gen=%d', + this._rebuildGeneration + 1 + ); + + // 停止旧容器的 postRender 同步(旧 popup 在新容器就绪前保持显示) + this.clearBatchPopupPostRenderSync(); + + const oldContainer = this.batchPopupContainer; + const generation = ++this._rebuildGeneration; + + // 在隐藏容器中构建新 popup(尚未插入 DOM) + const newContainer = document.createElement('div'); + newContainer.className = 'batch-popup-container'; + newContainer.style.cssText = + 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:1000;'; + newContainer.id = 'batch-popup-container'; + + this.batchPopupContainer = newContainer; + this.batchPopupItems = []; + this.popupCollisionFrames = new WeakMap(); + + const candidates = this.collectBatchPopupCandidates(); + this.buildBatchPopupsInContainer(newContainer, candidates).then(() => { + // 如果期间触发了新的重建,丢弃本次结果 + if (generation !== this._rebuildGeneration) { + newContainer.remove(); + return; + } + if (!this.isBatchPopupMode) { + newContainer.remove(); + return; + } + // 双缓冲替换:移除旧容器 → 插入新容器 → 注册 postRender + if (oldContainer) oldContainer.remove(); + this.containerElement!.appendChild(newContainer); + this.setupBatchPopupPostRenderSync(); + }); } /** 清除所有批量 popup */ private clearBatchPopups() { this.batchPopupItems = []; + this.popupCollisionFrames = new WeakMap(); if (this.batchPopupContainer) { this.batchPopupContainer.remove(); this.batchPopupContainer = null; @@ -762,32 +1055,90 @@ export class MapCesium implements MapInterface { // ==================== destroy ==================== destroy(): void { + // 取消飞行超时和加载动画 this.clearFlightFallbackTimer(); this.hideLoadingOverlay(); + + // 批量 popup 和 hover popup this.isBatchPopupMode = false; this.clearBatchPopupPostRenderSync(); this.clearBatchPopups(); this.hidePopup(); + + // hover 节流 requestAnimationFrame + if (this.hoverRafId !== null) { + cancelAnimationFrame(this.hoverRafId); + this.hoverRafId = null; + } + + // 相机事件监听 if (this.removePostRenderListener) { this.removePostRenderListener(); this.removePostRenderListener = null; } + if (this.removeCameraChangedListener) { + this.removeCameraChangedListener(); + this.removeCameraChangedListener = null; + } + + // 碰撞检测 rAF / 定时器 + if (this.collisionRefreshFrameId !== null) { + cancelAnimationFrame(this.collisionRefreshFrameId); + this.collisionRefreshFrameId = null; + } + if (this.labelVisibilityDebounceTimerId !== null) { + window.clearTimeout(this.labelVisibilityDebounceTimerId); + this.labelVisibilityDebounceTimerId = null; + } + + // 中止进行中的基地裁切请求 + if (this.clipRequestController) { + this.clipRequestController.abort(); + this.clipRequestController = null; + } + + // 清理遮罩多边形 Entity(需在 viewer.destroy 前清除) + if (this.viewer && !this.viewer.isDestroyed()) { + this.maskPolygonEntities.forEach(entity => { + this.viewer!.entities.remove(entity); + }); + } + this.maskPolygonEntities = []; + + // 清理锚点图层和底图注册表 this.destroyAllPointLayers(); this.pointLayerRegistry.clear(); this.baseLayerRegistry.clear(); this.baseLayerAliasMap.clear(); this.imageryBaseLayer = null; + + // 清理 Cesium 事件处理器 if (this.clickEventHandler) { this.clickEventHandler.destroy(); this.clickEventHandler = null; } this.popupElement = null; + + // 销毁 Cesium Viewer if (this.viewer) { + this._rebuildGeneration++; // 中止进行中的双缓冲重建 this.viewer.camera.cancelFlight(); this.viewer.destroy(); this.viewer = null; } this.containerElement = null; + + // 重置所有状态变量(确保下次 init 时是干净状态) + this.popupCollisionFrames = new WeakMap(); + this.currentClipGeoJson = null; + this.originalBgColor = null; + this.skyBoxShown = true; + this.skyAtmosphereShown = true; + this.hydropBaseConfig = null; + this.BASEID = ''; + this.hoveredEntityId = null; + + console.log('[Cesium] destroyed'); } private requestRender() { @@ -1243,10 +1594,16 @@ export class MapCesium implements MapInterface { this.viewer!.entities.add(entity); // 存储原始业务数据引用,避免点击时遍历 Cesium Property (entity as any)._rawData = item; + (entity as any)._labelText = labelText; // 可见性控制标志(组合计算 entity.show) (entity as any)._layerVisible = true; (entity as any)._legendVisible = true; (entity as any)._regionVisible = true; + // 碰撞检测可见性标志(初始均为可见,碰撞检测会按需更新) + (entity as any)._iconCollisionVisible = true; + (entity as any)._labelCollisionVisible = true; + // 初始应用可见性(确保 entity.show / entity.label.show 被显式设置) + this.applyEntityVisibility(entity); entities.push(entity); }); @@ -1256,9 +1613,12 @@ export class MapCesium implements MapInterface { if (this.currentClipGeoJson) { this.filterPointsByRegionForLayer( targetLayerKey, - this.currentClipGeoJson + this.extractPolygonCoords(this.currentClipGeoJson) ); } + + // 数据加载完成后触发首次碰撞检测(同步执行,避免异步 rAF 导致的首帧闪烁) + this.requestRefreshPointLabelVisibility(true); } mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void { @@ -1270,6 +1630,11 @@ export class MapCesium implements MapInterface { (entity as any)._layerVisible = visible; this.applyEntityVisibility(entity); }); + + // 图层切换后立即重算碰撞(同步执行,避免闪烁) + this.requestRefreshPointLabelVisibility(true); + // 批量模式下同步重建 popup + this.rebuildBatchPopupsIfNeeded(); } removePointLayer(layerKey: string): void { @@ -1350,9 +1715,7 @@ export class MapCesium implements MapInterface { return; } - // 请求前先隐藏全部锚点(避免 API 响应前锚点短暂错误显示) - this.setAllPointsRegionVisible(false); - + // 用户从"全部"进入,锚点本来就全可见,无需 API 前先隐藏(避免全量遍历卡顿) this.clipRequestController = new AbortController(); const signal = this.clipRequestController.signal; @@ -1367,9 +1730,10 @@ export class MapCesium implements MapInterface { if (this.BASEID !== _regionId) return; this.currentClipGeoJson = geoJsonData; - this.filterPointsByRegion(geoJsonData); - this.fitViewToGeoJson(geoJsonData); - this.createMaskPolygon(geoJsonData); + const regionCoords = this.extractPolygonCoords(geoJsonData); + this.filterPointsByRegion(regionCoords); + this.fitViewToGeoJson(regionCoords); + this.createMaskPolygon(regionCoords); } catch (error: any) { if (error.name === 'AbortError') return; this.currentClipGeoJson = null; @@ -1403,6 +1767,8 @@ export class MapCesium implements MapInterface { (entity as any)._legendVisible = false; this.applyEntityVisibility(entity); }); + this.requestRefreshPointLabelVisibility(true); + this.rebuildBatchPopupsIfNeeded(); return; } @@ -1414,6 +1780,10 @@ export class MapCesium implements MapInterface { this.applyEntityVisibility(entity); } }); + + // 图例切换后立即重算碰撞(同步执行,避免闪烁) + this.requestRefreshPointLabelVisibility(true); + this.rebuildBatchPopupsIfNeeded(); } // ==================== 基地裁切辅助方法 ==================== @@ -1438,8 +1808,7 @@ export class MapCesium implements MapInterface { } /** 根据 GeoJSON 边界批量更新锚点的区域显隐状态(射线法) */ - private filterPointsByRegion(geoJson: any): void { - const regionCoords = this.extractPolygonCoords(geoJson); + private filterPointsByRegion(regionCoords: number[][][]): void { if (!regionCoords.length) { console.warn('无法解析区域边界,显示所有锚点'); this.setAllPointsRegionVisible(true); @@ -1447,17 +1816,17 @@ export class MapCesium implements MapInterface { } this.pointLayerRegistry.forEach((_entities, layerKey) => { - this.filterPointsByRegionForLayer(layerKey, geoJson); + this.filterPointsByRegionForLayer(layerKey, regionCoords); }); } /** 对单个图层应用基地裁切过滤 */ - private filterPointsByRegionForLayer(layerKey: string, geoJson: any): void { + private filterPointsByRegionForLayer( + layerKey: string, + regionCoords: number[][][] + ): void { const entities = this.pointLayerRegistry.get(layerKey); - if (!entities) return; - - const regionCoords = this.extractPolygonCoords(geoJson); - if (!regionCoords.length) return; + if (!entities || !regionCoords.length) return; entities.forEach(entity => { const rawData = (entity as any)._rawData; @@ -1485,13 +1854,12 @@ export class MapCesium implements MapInterface { }); } - /** 根据 GeoJSON 范围飞行相机到基地边界 */ - private fitViewToGeoJson(geoJson: any): void { + /** 根据多边形坐标飞行相机到基地边界 */ + private fitViewToGeoJson(regionCoords: number[][][]): void { if (!this.viewer) return; try { - const coords = this.extractPolygonCoords(geoJson); - if (!coords.length) return; + if (!regionCoords.length) return; // 计算所有坐标的经纬度范围 let minLon = Infinity; @@ -1499,7 +1867,7 @@ export class MapCesium implements MapInterface { let minLat = Infinity; let maxLat = -Infinity; - coords.forEach(ring => { + regionCoords.forEach(ring => { ring.forEach(([lon, lat]) => { if (lon < minLon) minLon = lon; if (lon > maxLon) maxLon = lon; @@ -1528,22 +1896,38 @@ export class MapCesium implements MapInterface { } /** 应用基地裁切:globe.clippingPolygons 直接裁切地球渲染 */ - private createMaskPolygon(geoJson: any): void { + private createMaskPolygon(regionCoords: number[][][]): void { if (!this.viewer) return; - this.clearMaskPolygon(); + // 仅清理旧边框(不重置 clipping/背景色/skyBox,避免 GPU 状态反复重建) + this.maskPolygonEntities.forEach(entity => { + if (!this.viewer!.isDestroyed()) { + this.viewer!.entities.remove(entity); + } + }); + this.maskPolygonEntities = []; - const regionCoords = this.extractPolygonCoords(geoJson); if (!regionCoords.length) return; - // 预计算 Cartesian3 坐标数组,裁剪 + 边框共用,避免重复 fromDegreesArray const cartesianRings = regionCoords.map(ring => Cesium.Cartesian3.fromDegreesArray( ring.flatMap(([lon, lat]) => [lon, lat]) ) ); - // globe.clippingPolygons:GPU 层面裁切地球,保留基地边界内部,外部不渲染 + // 首次进入裁切时保存原始状态 + if (!this.originalBgColor) { + this.originalBgColor = this.viewer.scene.backgroundColor.clone(); + this.skyBoxShown = this.viewer.scene.skyBox?.show ?? true; + this.skyAtmosphereShown = this.viewer.scene.skyAtmosphere?.show ?? true; + } + // 边界外设为白色 + if (this.viewer.scene.skyBox) this.viewer.scene.skyBox.show = false; + if (this.viewer.scene.skyAtmosphere) + this.viewer.scene.skyAtmosphere.show = false; + this.viewer.scene.backgroundColor = Cesium.Color.WHITE; + + // 直接设新裁切(不经过空集合,避免 GPU shader 重复编译) this.viewer.scene.globe.clippingPolygons = new Cesium.ClippingPolygonCollection({ polygons: cartesianRings.map( @@ -1553,17 +1937,6 @@ export class MapCesium implements MapInterface { enabled: true }); - // 边界外背景设为白色:保存并隐藏星空/大气层,否则会遮挡背景色 - if (!this.originalBgColor) { - this.originalBgColor = this.viewer.scene.backgroundColor.clone(); - this.skyBoxShown = this.viewer.scene.skyBox?.show ?? true; - this.skyAtmosphereShown = this.viewer.scene.skyAtmosphere?.show ?? true; - } - if (this.viewer.scene.skyBox) this.viewer.scene.skyBox.show = false; - if (this.viewer.scene.skyAtmosphere) - this.viewer.scene.skyAtmosphere.show = false; - this.viewer.scene.backgroundColor = Cesium.Color.WHITE; - // 绘制基地边界线(双线视觉效果:紫色 + 浅紫色) const borderStyles = [ { width: 3, color: '#6D64DF' }, @@ -1675,9 +2048,659 @@ export class MapCesium implements MapInterface { return inside; } - /** 组合计算 entity.show:layerVisible && legendVisible && regionVisible */ + /** + * 组合计算可见性(3D 解耦模型): + * - entity.show = 基础可见(layer && legend && region),保持 entity 始终"在作用域内" + * - entity.billboard.show = 基础可见 && 图标碰撞通过 + * - entity.label.show = 基础可见 && 标签碰撞通过(图标碰撞不影响标签) + */ private applyEntityVisibility(entity: Cesium.Entity): void { const e = entity as any; - entity.show = e._layerVisible && e._legendVisible && e._regionVisible; + const baseVisible = e._layerVisible && e._legendVisible && e._regionVisible; + // entity 整体始终基于 layer/legend/region(不受碰撞影响,保证 popup/click 等交互不受碰撞干扰) + entity.show = baseVisible; + // 图标单独受碰撞控制 + if (entity.billboard) { + (entity.billboard as any).show = + baseVisible && e._iconCollisionVisible !== false; + } + // 标签单独受碰撞控制(不与图标碰撞联动) + if (entity.label) { + (entity.label as any).show = + baseVisible && e._labelCollisionVisible !== false; + } + } + + // ==================== Step 6: 碰撞检测 ==================== + + /** + * 触发碰撞检测刷新(带 rAF 节流,与 2D requestRefreshPointLabelVisibility 对齐) + */ + private requestRefreshPointLabelVisibility( + immediate = false, + debounceMs = 0 + ) { + if (this.labelVisibilityDebounceTimerId !== null) { + window.clearTimeout(this.labelVisibilityDebounceTimerId); + this.labelVisibilityDebounceTimerId = null; + } + + if (immediate) { + if (this.collisionRefreshFrameId !== null) { + cancelAnimationFrame(this.collisionRefreshFrameId); + this.collisionRefreshFrameId = null; + } + this.refreshPointLabelVisibility(); + return; + } + + if (debounceMs > 0) { + this.labelVisibilityDebounceTimerId = window.setTimeout(() => { + this.labelVisibilityDebounceTimerId = null; + this.requestRefreshPointLabelVisibility(); + }, debounceMs); + return; + } + + if (this.collisionRefreshFrameId !== null) return; + + this.collisionRefreshFrameId = window.requestAnimationFrame(() => { + this.collisionRefreshFrameId = null; + this.refreshPointLabelVisibility(); + }); + } + + /** + * 主碰撞检测:密度门控 → 图标碰撞 → 标签碰撞 + * 与 2D refreshPointLabelVisibility 核心算法完全对齐 + */ + private refreshPointLabelVisibility(): void { + if (!this.viewer) return; + + const currentZoom = this.getCurrentCesiumZoom(); + if (currentZoom === undefined || currentZoom === null) return; + + console.log( + `[Cesium Collision] zoom=${currentZoom?.toFixed(2)} layers=${ + this.pointLayerRegistry.size + }` + ); + + // ===== 阶段0:收集视口内所有基础可见实体 ===== + type CollisionCandidate = { + entity: Cesium.Entity; + iconLeft: number; + iconRight: number; + iconTop: number; + iconBottom: number; + hasLabel: boolean; + labelLeft: number; + labelRight: number; + labelTop: number; + labelBottom: number; + engPriority: number; + nearbyPriority: number; + densityPriority: number; + pixelX: number; + pixelY: number; + id: string; + }; + + const candidates: CollisionCandidate[] = []; + const canvas = this.viewer.scene.canvas; + const viewPadding = 120; // 视口外的宽容范围 + + // 孤立点密度网格:用于 shouldBypassDensityGate + const densityPixelGrid = new Map< + string, + Array<{ + entity: Cesium.Entity; + layerKey: string; + pixelX: number; + pixelY: number; + }> + >(); + + const dynamicScale = Math.max( + 0.5, + Math.min(3.0, 0.7 + (currentZoom - 4.5) * 0.08) + ); + const fontSize = Math.max(10, Math.min(24, 12 * dynamicScale)); + + // 第一遍:密度门控 + 构建候选 + this.pointLayerRegistry.forEach((entities, layerKey) => { + entities.forEach(entity => { + const e = entity as any; + + // 基础可见性检查(layer/legend/region) + if ( + e._layerVisible === false || + e._legendVisible === false || + e._regionVisible === false + ) { + e._iconCollisionVisible = false; + e._labelCollisionVisible = false; + this.applyEntityVisibility(entity); + return; + } + + const position = entity.position?.getValue( + this.viewer!.clock.currentTime + ); + if (!position) { + e._iconCollisionVisible = false; + e._labelCollisionVisible = false; + this.applyEntityVisibility(entity); + return; + } + + const screenPos = Cesium.SceneTransforms.worldToWindowCoordinates( + this.viewer!.scene, + position + ); + if (!screenPos) { + e._iconCollisionVisible = false; + e._labelCollisionVisible = false; + this.applyEntityVisibility(entity); + return; + } + + const pixelX = screenPos.x; + const pixelY = screenPos.y; + + // 视口裁剪:太远的实体不参与碰撞,但保留其当前碰撞状态(不重置) + if ( + pixelX < -viewPadding || + pixelY < -viewPadding || + pixelX > canvas.clientWidth + viewPadding || + pixelY > canvas.clientHeight + viewPadding + ) { + return; + } + + // 密度 gating(与 2D getFeatureDensityMinZoom / shouldRenderFeatureByDensity 对齐) + const densityMinZoom = this.getEntityDensityMinZoom(entity); + const bypassDensity = + currentZoom < densityMinZoom && + this.shouldBypassDensityGateForIsolatedEntity( + entity, + pixelX, + pixelY, + layerKey, + densityPixelGrid + ); + const densityVisible = currentZoom >= densityMinZoom || bypassDensity; + + if (!densityVisible) { + e._iconCollisionVisible = false; + e._labelCollisionVisible = false; + this.applyEntityVisibility(entity); + return; + } + + // 构建图标碰撞盒(与 2D 一致:Math.max(14, 24 * dynamicScale)) + const iconCollisionSize = Math.max(14, 24 * dynamicScale); + const iconLeft = pixelX - iconCollisionSize / 2; + const iconRight = pixelX + iconCollisionSize / 2; + const iconTop = pixelY - iconCollisionSize / 2; + const iconBottom = pixelY + iconCollisionSize / 2; + + // 构建标签碰撞盒 + const labelText: string = e._labelText || ''; + const hasLabel = !!labelText; + let labelLeft = 0; + let labelRight = 0; + let labelTop = 0; + let labelBottom = 0; + + if (hasLabel) { + const lines = labelText.split('\n'); + const maxLineLength = Math.max( + ...lines.map((l: string) => l.length), + 1 + ); + const labelLineCount = lines.length; + // 标签碰撞偏移与 2D getPointLabelCollisionOffsetY 对齐 + const labelOffsetY = + labelLineCount > 1 ? -36 * dynamicScale : -28 * dynamicScale; + const estimatedWidth = maxLineLength * fontSize * 0.6 + 16; + const estimatedHeight = labelLineCount * (fontSize + 4) + 8; + const centerY = pixelY + labelOffsetY; + labelLeft = pixelX - estimatedWidth / 2; + labelRight = pixelX + estimatedWidth / 2; + labelTop = centerY - estimatedHeight / 2; + labelBottom = centerY + estimatedHeight / 2; + } + + const entityId = String(entity.id || ''); + const rawData: Record = e._rawData || {}; + + candidates.push({ + entity, + iconLeft, + iconRight, + iconTop, + iconBottom, + hasLabel, + labelLeft, + labelRight, + labelTop, + labelBottom, + engPriority: this.getEntityEngRenderPriority(entity), + nearbyPriority: Number(rawData._nearbyPriority || 9999), + densityPriority: this.getEntityDensityPriority(entity), + pixelX, + pixelY, + id: entityId + }); + }); + }); + + if (candidates.length === 0) return; + + // ===== 阶段1:排序(eng优先级 > 近邻点优先级 > 密度优先级 > Y轴 > ID) ===== + candidates.sort((left, right) => { + if (left.engPriority !== right.engPriority) { + return right.engPriority - left.engPriority; + } + if (left.nearbyPriority !== right.nearbyPriority) { + return left.nearbyPriority - right.nearbyPriority; + } + if (left.densityPriority !== right.densityPriority) { + return left.densityPriority - right.densityPriority; + } + if (left.pixelY !== right.pixelY) { + return left.pixelY - right.pixelY; + } + return left.id.localeCompare(right.id); + }); + + // ===== 阶段2:图标碰撞(AABB + 空间网格加速) ===== + const iconPlacedGrid = new Map< + string, + Array<{ + left: number; + right: number; + top: number; + bottom: number; + }> + >(); + + candidates.forEach(candidate => { + const iconRect = { + left: candidate.iconLeft, + right: candidate.iconRight, + top: candidate.iconTop, + bottom: candidate.iconBottom + }; + + const hasCollision = this.findCollidingRectInGrid( + iconRect, + iconPlacedGrid, + this.COLLISION_GRID_CELL_SIZE, + placed => this.checkIconsAabbCollision(placed, iconRect) + ); + + const iconVisible = !hasCollision; + (candidate.entity as any)._iconCollisionVisible = iconVisible; + + if (iconVisible) { + this.addRectToCollisionGrid( + iconRect, + iconPlacedGrid, + this.COLLISION_GRID_CELL_SIZE + ); + } + }); + + // ===== 阶段3:标签碰撞(先避让图标,再避让标签) ===== + const labelCandidates = [...candidates].sort((left, right) => { + if (left.engPriority !== right.engPriority) + return right.engPriority - left.engPriority; + if (left.nearbyPriority !== right.nearbyPriority) + return left.nearbyPriority - right.nearbyPriority; + if (left.pixelY !== right.pixelY) return left.pixelY - right.pixelY; + if (left.pixelX !== right.pixelX) return left.pixelX - right.pixelX; + return left.id.localeCompare(right.id); + }); + + const labelPlacedGrid = new Map< + string, + Array<{ + left: number; + right: number; + top: number; + bottom: number; + }> + >(); + + labelCandidates.forEach(candidate => { + const e = candidate.entity as any; + + if (!candidate.hasLabel) { + e._labelCollisionVisible = false; + return; + } + + // 3D 解耦:图标被碰撞隐藏不影响标签显示 + // 标签仍会避让已放置的图标(见下方 iconPlacedGrid 检查) + + const labelRect = { + left: candidate.labelLeft, + right: candidate.labelRight, + top: candidate.labelTop, + bottom: candidate.labelBottom + }; + + // 先避让已放置的图标(缩小保护区4px,允许边缘擦过) + const hasIconCollision = this.findCollidingRectInGrid( + labelRect, + iconPlacedGrid, + this.COLLISION_GRID_CELL_SIZE, + iconRect => { + const inset = 4; + const narrowed = { + left: iconRect.left + inset, + right: iconRect.right - inset, + top: iconRect.top + inset, + bottom: iconRect.bottom - inset + }; + return !( + narrowed.right <= labelRect.left || + narrowed.left >= labelRect.right || + narrowed.bottom <= labelRect.top || + narrowed.top >= labelRect.bottom + ); + } + ); + + if (hasIconCollision) { + e._labelCollisionVisible = false; + return; + } + + // 再避让已放置的标签 + const hasLabelCollision = this.findCollidingRectInGrid( + labelRect, + labelPlacedGrid, + this.COLLISION_GRID_CELL_SIZE, + placed => this.checkLabelAabbCollision(placed, labelRect) + ); + + const labelVisible = !hasLabelCollision; + e._labelCollisionVisible = labelVisible; + + if (labelVisible) { + this.addRectToCollisionGrid( + labelRect, + labelPlacedGrid, + this.COLLISION_GRID_CELL_SIZE + ); + } + }); + + // ===== 阶段4:统一应用可见性变化 ===== + let iconVisibleCount = 0; + let labelVisibleCount = 0; + candidates.forEach(candidate => { + const e = candidate.entity as any; + if (e._iconCollisionVisible) iconVisibleCount++; + if (e._labelCollisionVisible) labelVisibleCount++; + this.applyEntityVisibility(candidate.entity); + }); + + console.log( + `[Cesium Collision] 候选=${candidates.length} 图标可见=${iconVisibleCount} 标签可见=${labelVisibleCount}` + ); + } + + // ==================== 碰撞检测辅助:密度门控 ==================== + + /** 从实体原始数据中提取 density 字段(密度分档值,非物理距离) */ + private getEntityDensityValue(entity: Cesium.Entity): number | null { + const rawData = (entity as any)._rawData; + if (!rawData) return null; + const rawDistance = rawData.distance; + if ( + rawDistance === undefined || + rawDistance === null || + rawDistance === '' + ) { + return null; + } + const densityValue = Number(rawDistance); + return Number.isFinite(densityValue) ? densityValue : null; + } + + /** 密度优先级(档位越低越优先,与 2D getFeatureDensityPriority 对齐) */ + private getEntityDensityPriority(entity: Cesium.Entity): number { + const densityValue = this.getEntityDensityValue(entity); + const densityDisplayRules = getNearbyPointDensityDisplayRules(); + if (densityValue === null) { + return densityDisplayRules.length; + } + const matchedRuleIndex = densityDisplayRules.findIndex(rule => { + return densityValue >= rule.minDensityValue; + }); + return matchedRuleIndex >= 0 + ? matchedRuleIndex + : densityDisplayRules.length; + } + + /** 根据密度值获取最低显示缩放级别(与 2D getFeatureDensityMinZoom 对齐) */ + private getEntityDensityMinZoom(entity: Cesium.Entity): number { + const densityValue = this.getEntityDensityValue(entity); + if (densityValue === null) return 0; + const densityDisplayRules = getNearbyPointDensityDisplayRules(); + const matchedRule = densityDisplayRules.find(rule => { + return densityValue >= rule.minDensityValue; + }); + if (matchedRule) return matchedRule.minZoom; + return densityDisplayRules.at(-1)?.minZoom ?? 0; + } + + /** + * 孤立点密度豁免:如果当前视口内 56px 范围内没有其他同图层可见点, + * 则跳过密度门控直接显示(与 2D shouldBypassDensityGateForIsolatedFeature 对齐) + */ + private shouldBypassDensityGateForIsolatedEntity( + entity: Cesium.Entity, + pixelX: number, + pixelY: number, + layerKey: string, + densityPixelGrid: Map< + string, + Array<{ + entity: Cesium.Entity; + layerKey: string; + pixelX: number; + pixelY: number; + }> + > + ): boolean { + // 先登记当前实体到密度网格 + const gridKey = `${layerKey}:${Math.floor( + pixelX / this.DENSITY_ISOLATION_THRESHOLD + )}:${Math.floor(pixelY / this.DENSITY_ISOLATION_THRESHOLD)}`; + const bucket = densityPixelGrid.get(gridKey) || []; + bucket.push({ entity, layerKey, pixelX, pixelY }); + densityPixelGrid.set(gridKey, bucket); + + // 检查周围 3x3 网格单元 + const checkedKeys = new Set(); + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + const neighborKey = `${layerKey}:${ + Math.floor(pixelX / this.DENSITY_ISOLATION_THRESHOLD) + dx + }:${Math.floor(pixelY / this.DENSITY_ISOLATION_THRESHOLD) + dy}`; + if (checkedKeys.has(neighborKey)) continue; + checkedKeys.add(neighborKey); + + const neighborBucket = densityPixelGrid.get(neighborKey); + if (!neighborBucket?.length) continue; + + for (const neighbor of neighborBucket) { + if (neighbor.entity === entity) continue; + const dist = Math.hypot( + neighbor.pixelX - pixelX, + neighbor.pixelY - pixelY + ); + if (dist <= this.DENSITY_ISOLATION_THRESHOLD) { + return false; // 有近邻点,不豁免 + } + } + } + } + + return true; // 孤立点,豁免密度门控 + } + + // ==================== 碰撞检测辅助:eng 优先级 ==================== + + /** 判断是否为 eng 点(与 2D isFeatureEng 对齐) */ + private isEntityEng(entity: Cesium.Entity): boolean { + const e = entity as any; + const rawData: Record = e._rawData || {}; + const layerKey = String(e._layerKey || '').toLowerCase(); + const sttpMap = String( + rawData.sttpMap || rawData._sttpMap || '' + ).toUpperCase(); + const sttpCode = String( + rawData.sttpCode || rawData.sttp || '' + ).toUpperCase(); + + return ( + layerKey.includes('eng_point') || + sttpMap === 'ENG' || + sttpMap === 'ENG2' || + sttpCode === 'ENG' + ); + } + + /** 判断是否为 eng 告警点(与 2D isFeatureEngAlarm 对齐) */ + private isEntityEngAlarm(entity: Cesium.Entity): boolean { + const e = entity as any; + const rawData: Record = e._rawData || {}; + const layerKey = String(e._layerKey || '').toLowerCase(); + const legendState = String(rawData.anchoPointState || '') + .trim() + .toLowerCase(); + + return ( + layerKey.includes('eng_alarm_point') || + layerKey.includes('alarm_range') || + legendState.startsWith('alarm_range_') || + legendState.startsWith('large_eng_built_alarm_range_') || + legendState.startsWith('mid_eng_built_alarm_range_') + ); + } + + /** 获取 eng 渲染优先级:告警=300, eng=200, 普通=100(与 2D 完全一致) */ + private getEntityEngRenderPriority(entity: Cesium.Entity): number { + if (this.isEntityEngAlarm(entity)) return 300; + return this.isEntityEng(entity) ? 200 : 100; + } + + // ==================== 碰撞检测辅助:空间网格与 AABB ==================== + + /** 计算矩形覆盖的碰撞网格 cell keys */ + private getCollisionGridKeys( + rect: { + left: number; + right: number; + top: number; + bottom: number; + }, + cellSize: number + ): string[] { + const startX = Math.floor(rect.left / cellSize); + const endX = Math.floor(rect.right / cellSize); + const startY = Math.floor(rect.top / cellSize); + const endY = Math.floor(rect.bottom / cellSize); + const keys: string[] = []; + + for (let x = startX; x <= endX; x += 1) { + for (let y = startY; y <= endY; y += 1) { + keys.push(`${x}:${y}`); + } + } + + return keys; + } + + /** 将矩形注册到碰撞网格 */ + private addRectToCollisionGrid< + T extends { + left: number; + right: number; + top: number; + bottom: number; + } + >(rect: T, grid: Map, cellSize: number) { + this.getCollisionGridKeys(rect, cellSize).forEach(key => { + const bucket = grid.get(key) || []; + bucket.push(rect); + grid.set(key, bucket); + }); + } + + /** 在碰撞网格中查找与给定矩形碰撞的矩形 */ + private findCollidingRectInGrid< + T extends { + left: number; + right: number; + top: number; + bottom: number; + } + >( + rect: { + left: number; + right: number; + top: number; + bottom: number; + }, + grid: Map, + cellSize: number, + isCollision: (candidate: T) => boolean + ): T | undefined { + for (const key of this.getCollisionGridKeys(rect, cellSize)) { + const bucket = grid.get(key); + if (!bucket?.length) continue; + + for (const candidate of bucket) { + if (isCollision(candidate)) { + return candidate; + } + } + } + + return undefined; + } + + /** 图标碰撞检测:AABB 重叠判定(padding=0,与 2D 图标碰撞一致) */ + private checkIconsAabbCollision( + left: { left: number; right: number; top: number; bottom: number }, + right: { left: number; right: number; top: number; bottom: number } + ): boolean { + return !( + left.right < right.left || + left.left > right.right || + left.bottom < right.top || + left.top > right.bottom + ); + } + + /** 标签碰撞检测:AABB 重叠 + padding=4(与 2D checkLabelCollision 对齐) */ + private checkLabelAabbCollision( + left: { left: number; right: number; top: number; bottom: number }, + right: { left: number; right: number; top: number; bottom: number } + ): boolean { + const padding = this.COLLISION_LABEL_PADDING; + return !( + left.right + padding < right.left || + left.left - padding > right.right || + left.bottom + padding < right.top || + left.top - padding > right.bottom + ); } } diff --git a/frontend/src/store/modules/map.ts b/frontend/src/store/modules/map.ts index 468b931e..99cbd6c0 100644 --- a/frontend/src/store/modules/map.ts +++ b/frontend/src/store/modules/map.ts @@ -122,19 +122,48 @@ const normalizeRequestParams = (rawParams: any) => { }; }; -const parseAnchorParamFilters = (anchorParamJson?: string) => { - if (!anchorParamJson) return []; +const parseAnchorParamJson = (anchorParamJson?: string) => { + const result: { filters: any[]; orders: any[] | null } = { + filters: [], + orders: null + }; + + if (!anchorParamJson) return result; try { - const parsedParams = JSON.parse(anchorParamJson); - if (!parsedParams || typeof parsedParams !== 'object') { - return []; + const parsed = JSON.parse(anchorParamJson); + if (!parsed || typeof parsed !== 'object') return result; + + // 解析 params -> filters(兼容旧格式:如果没有 params 字段,整个对象即为 params) + const paramsObj = parsed.params || parsed; + if ( + paramsObj && + typeof paramsObj === 'object' && + !Array.isArray(paramsObj) + ) { + result.filters = buildFiltersFromParamsObject(paramsObj); } - return buildFiltersFromParamsObject(parsedParams); + // 解析 orders -> sort 数组 + if (parsed.orders) { + let ordersObj: Record = {}; + if (typeof parsed.orders === 'string') { + ordersObj = JSON.parse(parsed.orders); + } else if (typeof parsed.orders === 'object') { + ordersObj = parsed.orders; + } + if (ordersObj && typeof ordersObj === 'object') { + result.orders = Object.entries(ordersObj).map(([field, dir]) => ({ + field, + dir + })); + } + } + + return result; } catch (error) { console.error('解析 anchorParamJson 失败:', error); - return []; + return result; } }; @@ -1158,9 +1187,12 @@ export const useMapStore = defineStore('map', () => { requestParams = normalizeRequestParams(requestParams); - const anchorParamFilters = parseAnchorParamFilters(anchorParamJson); - if (anchorParamFilters.length) { - requestParams.filters.push(...anchorParamFilters); + const anchorParamResult = parseAnchorParamJson(anchorParamJson); + if (anchorParamResult.filters.length) { + requestParams.filters.push(...anchorParamResult.filters); + } + if (anchorParamResult.orders) { + requestOrders = anchorParamResult.orders; } if (timeRangeLayerKeys.includes(layerKey)) { @@ -1194,7 +1226,7 @@ export const useMapStore = defineStore('map', () => { } } - if (layer?.orders) { + if (!requestOrders && layer?.orders) { try { const ordersObj = JSON.parse(layer.orders); const ordersArray = Object.entries(ordersObj).map(([field, dir]) => ({