From 1fc41ef1f62710a69c6128f265722d89b52da0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=88=E5=85=86=E5=A2=9E?= <你的邮箱@example.com> Date: Tue, 14 Jul 2026 16:09:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E7=89=883d=E4=BF=AE=E6=94=B9=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=EF=BC=8C=E5=B7=AE=E7=A2=B0=E6=92=9E=EF=BC=8Cpopup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.env.development | 6 +- frontend/docs/地图模块-3D Cesium实施计划.md | 87 +- frontend/src/components/gis/gisUtils.ts | 3 - frontend/src/components/gis/map.cesium.ts | 2021 ++++++++--------- frontend/src/components/gis/map.ol.ts | 72 +- .../components/gis/ol/point-layer-manager.ts | 101 +- frontend/src/components/mapFilter/index.vue | 5 +- frontend/src/components/mapLegend/index.vue | 4 +- frontend/src/modules/jidiSelectorMod.vue | 5 +- .../map/application/map-orchestrator.ts | 25 +- .../modules/map/domain/nearby-point-rules.ts | 132 +- .../modules/map/stores/map-config.store.ts | 3 +- frontend/src/store/modules/map.ts | 32 +- .../map/components/LegendData/index.vue | 2 +- 14 files changed, 1185 insertions(+), 1313 deletions(-) diff --git a/frontend/.env.development b/frontend/.env.development index 0eca7e7f..b2b28071 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -7,14 +7,14 @@ VITE_APP_TITLE = '水电水利建设项目全过程环境管理信息平台' VITE_APP_PORT = 3000 VITE_APP_BASE_API = '/dev-api' # 本地环境 -# VITE_APP_BASE_URL = 'http://10.84.121.127:8093' +VITE_APP_BASE_URL = 'http://localhost:8093' # 测试环境 # VITE_APP_BASE_URL = 'http://172.16.21.142:8093' # VITE_APP_BASE_URL = 'http://172.16.21.142:8096' # 汤伟 -# VITE_APP_BASE_URL = 'http://10.84.111.235:8093' +VITE_APP_BASE_URL = 'http://10.84.111.235:8093' # 李林 -VITE_APP_BASE_URL = 'http://10.84.111.25:8093' +# VITE_APP_BASE_URL = 'http://10.84.111.25:8093' # 测试环境线上10.84.121.122: VITE_APP_TEST_ONLINE_URL = 'https://211.99.26.225:12122' diff --git a/frontend/docs/地图模块-3D Cesium实施计划.md b/frontend/docs/地图模块-3D Cesium实施计划.md index 404e6040..f098dd6b 100644 --- a/frontend/docs/地图模块-3D Cesium实施计划.md +++ b/frontend/docs/地图模块-3D Cesium实施计划.md @@ -132,7 +132,7 @@ ## 5. 分步骤实施计划 -### Step 1:修正 3D 初始化并默认定位中国 +### Step 1:修正 3D 初始化并默认定位中国 ✅️ 目标: @@ -157,7 +157,7 @@ - 首屏有加载过渡。 - 进入后稳定落在中国。 -### Step 2:展示 3D 基础底图 +### Step 2:展示 3D 基础底图 ✅️ 目标: @@ -171,6 +171,10 @@ - 实现 `addBaseDataLayer(layer, isShow)`: - 对 `customBaseLayer` 使用 `url_3d` - 对 `BASEMAP-img`、`BASEMAP-white` 预留兼容能力 + - **必须处理 `wmts` 类型图层**:2D 通过 `WMTS` source 加载,3D 使用 `Cesium.WebMapTileServiceImageryProvider`,从 URL 参数中提取 LAYER/TILEMATRIXSET + - **必须提供 `tileMatrixLabels`**:Cesium 默认用纯数字作为 tileMatrix 参数值,但本系统 WMTS 服务器要求 `TileMatrixSet:zoom` 格式(如 `EPSG:3857_hbb_zrbhq_l13:4`)。需构建 `["TileMatrixSet:0", "TileMatrixSet:1", ...]` 传入 `tileMatrixLabels`。 + - 其他类型:`raster-dem`→XYZ、MapServer→ArcGIS、其余 →WMS +- **注意**:还存在一个 `key = "powerBaseStation"` 的基地底图图层,该图层**初始化时不加载**(已在 `IGNORED_BASE_LAYER_KEYS` 中忽略),但后续可通过图层树手动勾选显示。 - 实现 `controlBaseLayerTreeShowAndHidden()`: - 让图层树勾选能控制 3D 底图显隐 - 根据你的要求,UI 层先不开放 `BaseLayerSwitcher` 的 3D 切换行为,只保留默认基础底图加载。 @@ -180,7 +184,7 @@ - 切到 3D 后可稳定看到基础底图。 - 图层初始化时 `addBaseDataLayer(this.baseLayerConfig, checked, true)` 的思路在 3D 可落地。 -### Step 3:打通 3D 锚点展示 +### Step 3:打通 3D 锚点展示 ✅️ 目标: @@ -189,13 +193,14 @@ 计划: -- 在 `MapCesium` 内建立点位图层注册结构,建议按 `layerKey -> entity[]` 或 `CustomDataSource` 管理。 +- 在 `MapCesium` 内建立点位图层注册结构,按 `layerKey -> entity[]` 管理。 - 实现 `addInitDataLayer(pointData, layerType)`: - 复用当前点位数据结构 - 解析 `lgtd/lttd/iconCode/titleName/stnm/anchoPointState/popupHtml` - 复用现有图标资源路径 + - Entity ID 使用 `${layerKey}:${item._id || `${iconType || 'point'}_${stcd}`}`,与 2D 对齐,确保同 stcd 不同 sttpMap 的点不被覆盖 - 确保 `eng_point` 仍按现有 `mapStore` 过滤后的数据展示,中型站点只在 `7.5` 以上层级出现 - - 为后续缩放驱动的增量显示预留运行态字段,不把所有点简单一次性硬渲染 + - 为后续缩放驱动的增量显示预留运行态字段(`_legendVisible`、`_regionVisible`、`_densityVisible`),不把所有点简单一次性硬渲染 - 实现 `mdLayerTreeShowOrHidden(layerType, checked)`: - 图层树控制整个点图层显隐 - 实现 `removePointLayer()` / `hasLayer()` @@ -208,7 +213,34 @@ - 取消勾选图层后,对应锚点消失。 - `eng_point` 在低于 `7.5` 时不显示中型水电站,高于 `7.5` 时正常显示。 -### Step 4:打通 3D popup 与 hover +### Step 4:打通 3D 基地裁切(jdPanelControlShowAndHidden) ✅️ + +目标: + +- 3D 点击基地面板后,与 2D 行为一致。 +- 加载基地边界 GeoJSON,只显示区域内锚点,隐藏区域外锚点。 +- 取消基地时恢复显示所有锚点。 + +计划: + +- 在 `MapCesium` 中捕获 `hydropBase` 配置,与 2D 一样在 `addBaseDataLayer` 中记录。 +- 实现 `jdPanelControlShowAndHidden(baseid, isAll)`: + - `isAll=false`:清除 3D 球面遮罩实体、恢复所有锚点区域可见性、清除裁切状态。 + - `isAll=true`:先隐藏所有锚点,异步加载基地边界 GeoJSON,执行点在面判断后仅显示区域内锚点,同时在球面绘制裁切边界多边形(`applyRegionMask`),最后飞行到区域视野。 +- 在 `createEntityPropertiesBag` 中新增 `_regionVisible` 属性,初始为 `true`。 +- 在 `applyPointVisibilityRefresh` 中跳过 `_regionVisible !== false` 的实体,确保裁切后碰撞检测不会把区域外锚点重新显示出来。 +- 复用 `extractPolygonCoords` / `isPointInRing` 射线法判断逻辑(与 `region-mask-manager.ts` 一致)。 +- 支持 `AbortController` 取消前一次未完成的请求,避免响应顺序错乱。 +- `applyRegionMask` 将 GeoJSON 多边形转换为 Cesium `PolygonHierarchy`,在球面绘制半透明填充 + 可见边界的区域多边形,模拟 2D 底图 Canvas 裁切遮罩的视觉效果。 + +验收: + +- 点击基地后,3D 只显示该基地区域内的锚点,区域外锚点隐藏。 +- 取消基地后,所有锚点恢复正常显示。 +- 快速切换基地不会出现旧数据覆盖新数据的问题。 +- 多次切换 2D/3D 后基地裁切行为正常。 + +### Step 5:打通 3D popup 与 hover 目标: @@ -233,7 +265,7 @@ - 样式和 2D 保持一致。 - 移出后 popup 正常消失。 -### Step 5:补齐缩放驱动显示与碰撞检测 +### Step 6:补齐缩放驱动显示与碰撞检测 目标: @@ -263,7 +295,7 @@ - 放大到具体层级后会看到更多锚点逐步出现。 - 标签和锚点不会大面积重叠到不可读。 -### Step 6:打通 3D 锚点点击详情 +### Step 7:打通 3D 锚点点击详情 ✅️ 目标: @@ -287,7 +319,7 @@ - `ylfb` 和普通锚点分支行为一致。 - 点击空白区域不会误触发弹框。 -### Step 7:打通图例联动 +### Step 8:打通图例联动 ✅️ 目标: @@ -305,7 +337,7 @@ - 图例单项勾选/取消时,3D 对应类别锚点同步显隐。 - 图例分组勾选/取消时,3D 行为与 2D 保持一致。 -### Step 8:补足收口与稳定性 +### Step 9:补足收口与稳定性 目标: @@ -329,7 +361,7 @@ 1. 默认定位到中国 + 启动动画 2. 展示基础底图 -3. 展示锚点 +3. 展示锚点 3.5. 补基地裁切(jdPanelControlShowAndHidden) 4. 补 hover popup 5. 补缩放驱动显示与碰撞检测 6. 补锚点点击详情 @@ -349,6 +381,10 @@ - `stinfo/stinfo_video_point` 与 `stinfo_ai_video_point` 互斥 - `facilities` 与 `facilities_built` 两个分支互斥 - 点图层取消勾选时只隐藏,不删除缓存;重新勾选时优先从缓存恢复。 +- **图层树勾选时按 `type` 字段走不同分支**(见 `updateLayerData`): + - `type === 'pointMap'` → 走 `handlePointMapLayer` → 调用 `mdLayerTreeShowOrHidden`(控制锚点显隐) + - `type === 'GISMap'` → 走 `controlBaseLayerTreeShowAndHidden`(控制底图/叠加影像图层显隐) + - 3D Cesium 必须同时实现这两个方法才能与图层树完整联动。 ### 9.2 图例与点内显隐 @@ -360,11 +396,16 @@ - `eng_point` 的中型水电站只在 `ENG_POINT_MEDIUM_VISIBLE_ZOOM = 7.5` 以上显示。 - 水电开发页缩放跨过 `12` 级时,会自动补开一批动态图层;缩回去时再自动收口。 -- `2D` 存在“缩放越大显示越多”的行为,不是所有锚点在所有层级都等量显示。 +- `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 锚点碰撞与控量 @@ -395,6 +436,28 @@ - 鱼类分布模式会接管 `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` diff --git a/frontend/src/components/gis/gisUtils.ts b/frontend/src/components/gis/gisUtils.ts index 1776cc85..2466a5fd 100644 --- a/frontend/src/components/gis/gisUtils.ts +++ b/frontend/src/components/gis/gisUtils.ts @@ -42,9 +42,6 @@ export const legendData2Obj = (data: any[]) => { if (item?.childrenList && item.childrenList?.length > 0) { f(item.childrenList); } else { - if (item.nameEn.includes('_测试')) { - item.nameEn = item.nameEn.split('_测试')[0]; - } _tempData[item.nameEn] = item; } }); diff --git a/frontend/src/components/gis/map.cesium.ts b/frontend/src/components/gis/map.cesium.ts index aaac37c4..61cc9271 100644 --- a/frontend/src/components/gis/map.cesium.ts +++ b/frontend/src/components/gis/map.cesium.ts @@ -1,19 +1,13 @@ -// src/components/gis/map.cesium.ts -import * as Cesium from 'cesium'; -import { getIconPath } from '@/utils'; -import { generatePopupHtml } from '@/utils/popupHtmlGenerator'; +import * as Cesium from 'cesium'; import type { MDOptions } from './map.class'; import type { MapInterface, layer } from './map.d'; +import { getIconPath } from '@/utils/index'; import { useModelStore } from '@/store/modules/model'; -const modelStore = useModelStore(); - -// ✅ 1. 全局配置 Cesium 静态资源路径 (vite-plugin-cesium 通常会自动处理,但显式指定更保险) -// 注意:如果你使用了 vite-plugin-cesium,这一步通常不需要,但如果报错,可以尝试取消注释下面这行 -// Cesium.buildModuleUrl.setBaseUrl('/node_modules/cesium/Build/Cesium/'); - export class MapCesium implements MapInterface { private viewer: Cesium.Viewer | null = null; + private clickEventHandler: Cesium.ScreenSpaceEventHandler | null = null; + private popupElement: HTMLElement | null = null; private containerId = ''; private containerElement: HTMLElement | null = null; private loadingOverlayElement: HTMLDivElement | null = null; @@ -22,14 +16,16 @@ export class MapCesium implements MapInterface { private imageryBaseLayer: Cesium.ImageryLayer | null = null; private baseLayerRegistry = new Map(); private baseLayerAliasMap = new Map(); - private pointLayerRegistry = new Map(); - private pointLegendVisibility = new Map>(); - private pointEntityIndex = new Map>(); - private popupElement: HTMLDivElement | null = null; - private hoveredEntityId: string | null = null; - private removeCameraChangedListener: (() => void) | null = null; + private pointLayerRegistry = new Map(); + private hydropBaseConfig: any = null; + private BASEID = ''; + private currentClipGeoJson: any = null; + private clipRequestController: AbortController | null = null; + private maskPolygonEntities: Cesium.Entity[] = []; + private originalBgColor: Cesium.Color | null = null; + private skyBoxShown = true; + private skyAtmosphereShown = true; private readonly IGNORED_BASE_LAYER_KEYS = new Set([ - 'hydropBase', 'powerBaseStation', 's_hydropBase_wfs' ]); @@ -40,15 +36,11 @@ export class MapCesium implements MapInterface { 'BASEMAP-img' ]); - private readonly CHINA_CENTER = { - lng: 114.5, - lat: 36.5 - }; + private readonly CHINA_CENTER = { lng: 104.5, lat: 36.5 }; private readonly CHINA_BOOT_HEIGHT = 20000000; - private readonly CHINA_DEFAULT_HEIGHT = 12000000; - private readonly HOVER_POPUP_MAX_HEIGHT = 9000000; + private readonly CHINA_DEFAULT_HEIGHT = 9000000; private readonly CHINA_VIEW_ORIENTATION = { - heading: Cesium.Math.toRadians(0), + heading: Cesium.Math.toRadians(-6), pitch: Cesium.Math.toRadians(-90), roll: 0 }; @@ -57,6 +49,24 @@ export class MapCesium implements MapInterface { private readonly CESIUM_ZOOM_FORMULA_C = 91610.74; private readonly CESIUM_ZOOM_FORMULA_D = -40467.74; + // 图标缩放可调参数(只影响 3D Cesium,不参与 2D 计算) + // scale = base + (zoom - 4.5) * rate,钳位到 [min, max] + // zoom≈4.5 是中国全国视角,zoom≈15 是最大放大级别 + private readonly ICON_MIN_SCALE = 0.4; // 最小缩放 + private readonly ICON_MAX_SCALE = 1.2; // 最大缩放(限制放大后图标不超过 1.2x 基础值) + private readonly ICON_BASE_SCALE = 0.7; // zoom≈4.5 基准(中国视角图标 ≈ 原始尺寸) + private readonly ICON_SCALE_RATE = 0.035; // 每级 zoom 增长(放大 10 级才增加 ~0.35) + + // 标签文字可调参数 + // fontSize = clamp(base * scale, min, max),scale = 0.7 + (zoom-4.5)*0.08 + private readonly LABEL_FONT_MIN = 10; // 最小字号(px),改大 → 小缩放时文字更清晰 + private readonly LABEL_FONT_MAX = 20; // 最大字号(px) + private readonly LABEL_FONT_BASE = 16; // 基准乘数,改大 → 所有缩放级别文字都变大 + private readonly LABEL_OFFSET_SINGLE = 14; // 单行偏移系数,改大 → 文字离图标更远 + private readonly LABEL_OFFSET_MULTI = 30; // 多行偏移系数 + + // ==================== Step 1: 初始化 + 飞中国 ==================== + async init(container: HTMLElement, _rectangle?: any): Promise { try { this.containerId = container.id; @@ -75,7 +85,10 @@ export class MapCesium implements MapInterface { navigationHelpButton: false, infoBox: false, selectionIndicator: false, - shouldAnimate: true + shouldAnimate: true, + requestRenderMode: true, + targetFrameRate: 30, + terrainProvider: new Cesium.EllipsoidTerrainProvider() }); const layers = this.viewer.imageryLayers; @@ -94,16 +107,15 @@ export class MapCesium implements MapInterface { this.imageryBaseLayer.show = true; this.viewer.scene.globe.depthTestAgainstTerrain = false; + this.viewer.scene.globe.show = true; + this.viewer.scene.fog.enabled = false; + this.viewer.scene.fxaa = false; this.viewer.useBrowserRecommendedResolution = true; - this.bindHoverPopupHandler(); - this.bindPointClickHandler(); - this.bindCameraPopupReset(); - // 先把相机直接摆到中国上空,避免初始化阶段短暂闪到美国默认视角。 - this.setBootChinaView(); - await this.waitForViewerReady(); await this.flyToChina(); + this.setupClickHandler(); + return this.viewer; } catch (error) { this.hideLoadingOverlay(); @@ -112,31 +124,6 @@ export class MapCesium implements MapInterface { } } - private setBootChinaView() { - if (!this.viewer) return; - - this.viewer.camera.cancelFlight(); - this.viewer.camera.setView({ - destination: Cesium.Cartesian3.fromDegrees( - this.CHINA_CENTER.lng, - this.CHINA_CENTER.lat, - this.CHINA_BOOT_HEIGHT - ), - orientation: this.CHINA_VIEW_ORIENTATION - }); - } - - private waitForViewerReady(): Promise { - return new Promise(resolve => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - this.viewer?.scene.requestRender(); - resolve(); - }); - }); - }); - } - private flyToChina(): Promise { return new Promise(resolve => { if (!this.viewer) { @@ -145,27 +132,42 @@ export class MapCesium implements MapInterface { return; } - const finish = () => { - this.clearFlightFallbackTimer(); - this.hideLoadingOverlay(); - resolve(); - }; - - this.clearFlightFallbackTimer(); - this.flightFallbackTimer = window.setTimeout(() => { - finish(); - }, 2600); - - this.viewer.camera.flyTo({ + // Step 1: 瞬跳至高空俯视中国 + this.viewer.camera.cancelFlight(); + this.viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees( this.CHINA_CENTER.lng, this.CHINA_CENTER.lat, - this.CHINA_DEFAULT_HEIGHT + this.CHINA_BOOT_HEIGHT ), - orientation: this.CHINA_VIEW_ORIENTATION, - duration: 1.8, - complete: finish, - cancel: finish + orientation: this.CHINA_VIEW_ORIENTATION + }); + this.viewer.scene.requestRender(); + + // Step 2: 等一帧让 setView 生效后,飞入到默认视角 + requestAnimationFrame(() => { + const finish = () => { + this.clearFlightFallbackTimer(); + this.hideLoadingOverlay(); + resolve(); + }; + + this.clearFlightFallbackTimer(); + this.flightFallbackTimer = window.setTimeout(() => { + finish(); + }, 2600); + + this.viewer!.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees( + this.CHINA_CENTER.lng, + this.CHINA_CENTER.lat, + this.CHINA_DEFAULT_HEIGHT + ), + orientation: this.CHINA_VIEW_ORIENTATION, + duration: 1.8, + complete: finish, + cancel: finish + }); }); }); } @@ -177,753 +179,6 @@ export class MapCesium implements MapInterface { } } - private getRegistryKeys(layerConfig: any): string[] { - const keys = [layerConfig?.key, layerConfig?.id].filter( - (key): key is string => typeof key === 'string' && key.length > 0 - ); - return Array.from(new Set(keys)); - } - - private resolvePrimaryBaseLayerKey(layerKey: string): string | undefined { - if (!layerKey) return undefined; - if (this.baseLayerRegistry.has(layerKey)) { - return layerKey; - } - return this.baseLayerAliasMap.get(layerKey); - } - - private unregisterBaseLayer(layerConfig: any) { - if (!this.viewer) return; - - const registryKeys = this.getRegistryKeys(layerConfig); - const uniquePrimaryKeys = new Set(); - - registryKeys.forEach(key => { - const primaryKey = this.resolvePrimaryBaseLayerKey(key) || key; - uniquePrimaryKeys.add(primaryKey); - this.baseLayerAliasMap.delete(key); - }); - - uniquePrimaryKeys.forEach(primaryKey => { - const existingLayer = this.baseLayerRegistry.get(primaryKey); - if (existingLayer) { - this.viewer?.imageryLayers.remove(existingLayer, true); - this.baseLayerRegistry.delete(primaryKey); - } - }); - } - - private registerBaseLayer( - layerConfig: any, - imageryLayer: Cesium.ImageryLayer - ) { - const registryKeys = this.getRegistryKeys(layerConfig); - const primaryKey = layerConfig?.key || layerConfig?.id; - - if (!primaryKey) { - return; - } - - this.baseLayerRegistry.set(primaryKey, imageryLayer); - registryKeys.forEach(key => { - this.baseLayerAliasMap.set(key, primaryKey); - }); - } - - private getLayerRequestUrl(layerConfig: any): string { - return layerConfig?.url_3d || layerConfig?.url || ''; - } - - private createImageryLayer(layerConfig: any, visible: boolean) { - if (!this.viewer) { - return null; - } - - const url = this.getLayerRequestUrl(layerConfig); - if (!url) { - return null; - } - - let imageryLayer: Cesium.ImageryLayer | null = null; - - if (layerConfig?.type === 'raster-dem' || /\{x\}|\{y\}|\{z\}/.test(url)) { - imageryLayer = this.viewer.imageryLayers.addImageryProvider( - new Cesium.UrlTemplateImageryProvider({ - url - }) - ); - } else if (url.includes('MapServer')) { - imageryLayer = this.viewer.imageryLayers.add( - Cesium.ImageryLayer.fromProviderAsync( - Cesium.ArcGisMapServerImageryProvider.fromUrl(url, { - enablePickFeatures: false, - maximumLevel: 19 - }) - ) - ); - } else { - const cleanUrl = url.split('?')[0]; - const layers = layerConfig?.layers || ''; - imageryLayer = this.viewer.imageryLayers.addImageryProvider( - new Cesium.WebMapServiceImageryProvider({ - url: cleanUrl, - layers, - parameters: { - service: 'WMS', - transparent: 'true', - format: 'image/png' - } - }) - ); - } - - if (!imageryLayer) { - return null; - } - - imageryLayer.show = visible; - if (typeof layerConfig?.rasteropacity === 'number') { - imageryLayer.alpha = layerConfig.rasteropacity; - } - return imageryLayer; - } - - private getOrCreatePointLayer(layerKey: string) { - if (!this.viewer || !layerKey) { - return null; - } - - let dataSource = this.pointLayerRegistry.get(layerKey); - if (dataSource) { - return dataSource; - } - - dataSource = new Cesium.CustomDataSource(layerKey); - this.viewer.dataSources.add(dataSource); - this.pointLayerRegistry.set(layerKey, dataSource); - if (!this.pointLegendVisibility.has(layerKey)) { - this.pointLegendVisibility.set(layerKey, new Map()); - } - this.pointEntityIndex.set(layerKey, new Map()); - return dataSource; - } - - private resetPointLayerIndexes(layerKey: string) { - this.pointEntityIndex.set(layerKey, new Map()); - } - - private normalizeLegendState(value?: string) { - const normalizedValue = String(value || '').trim(); - if (!normalizedValue) { - return ''; - } - - const withoutTestSuffix = normalizedValue.includes('_测试') - ? normalizedValue.split('_测试')[0] - : normalizedValue; - - return withoutTestSuffix.startsWith('large_eng_built_alarm_range_') - ? withoutTestSuffix.replace('large_eng_built_', '') - : withoutTestSuffix; - } - - private getLayerLegendVisibility(layerKey: string, legendState: string) { - const layerStateMap = this.pointLegendVisibility.get(layerKey); - if (!layerStateMap) { - return true; - } - - const normalizedLegendState = - this.normalizeLegendState(legendState) || '__default__'; - if (layerStateMap.has(normalizedLegendState)) { - return layerStateMap.get(normalizedLegendState) ?? true; - } - - if (layerStateMap.has('__default__')) { - return layerStateMap.get('__default__') ?? true; - } - return true; - } - - private indexPointEntity( - layerKey: string, - legendState: string, - entity: Cesium.Entity - ) { - const layerIndex = this.pointEntityIndex.get(layerKey) || new Map(); - const normalizedLegendState = - this.normalizeLegendState(legendState) || '__default__'; - const entities = layerIndex.get(normalizedLegendState) || []; - entities.push(entity); - layerIndex.set(normalizedLegendState, entities); - this.pointEntityIndex.set(layerKey, layerIndex); - } - - private normalizePopupSttpMap(item: any, layerKey: string) { - if (item?.popupSttpMap) { - return item.popupSttpMap; - } - if (item?._sttpMap) { - return item._sttpMap; - } - if (item?.sttpMap) { - return item.sttpMap; - } - - const normalizedLayerKey = String(layerKey || '').toLowerCase(); - const sttpCode = String(item?.sttpCode || item?.sttp || '').toUpperCase(); - - if (normalizedLayerKey.includes('ylfb')) { - return 'ylfb'; - } - if (normalizedLayerKey.includes('fh_zq_point') || sttpCode === 'ZQ') { - return 'ZQ'; - } - if (normalizedLayerKey.includes('fh_wtrv_point') || sttpCode === 'WTRV') { - return 'eng3'; - } - if (normalizedLayerKey.includes('ai_video') || sttpCode === 'AIVD') { - return 'AIVD'; - } - if (normalizedLayerKey.includes('video') || sttpCode === 'VD') { - return 'VD'; - } - if (normalizedLayerKey.includes('wq') || sttpCode === 'WQ') { - return 'WQ'; - } - - return ''; - } - - private buildFallbackPopupHtml(props: any) { - const title = - props?.ennm || props?.stnm || props?.titleName || props?.ftp || ''; - if (!title) { - return ''; - } - - return ` -
-
-
${title}
-
-
- `; - } - - private buildPointPopupProps(item: any, layerKey: string) { - const normalizedSttpMap = this.normalizePopupSttpMap(item, layerKey); - return { - ...item, - sttpMap: normalizedSttpMap, - _sttpMap: normalizedSttpMap, - popupSttpMap: normalizedSttpMap - }; - } - - private createEntityPropertiesBag( - props: Record, - layerKey: string, - legendVisible: boolean - ) { - const propertyBag = new Cesium.PropertyBag(); - - Object.entries(props).forEach(([key, value]) => { - propertyBag.addProperty(key); - propertyBag[key] = value; - }); - - propertyBag.addProperty('_layerKey'); - propertyBag._layerKey = layerKey; - propertyBag.addProperty('_legendVisible'); - propertyBag._legendVisible = legendVisible; - - return propertyBag; - } - - private createPointEntity( - item: any, - layerKey: string, - entityIndex: number - ): Cesium.Entity | null { - const lon = Number(item?.lgtd); - const lat = Number(item?.lttd); - - if ( - !isFinite(lon) || - !isFinite(lat) || - Math.abs(lon) > 180 || - Math.abs(lat) > 90 - ) { - return null; - } - - const iconUrl = getIconPath(item?.iconCode || 'default') || ''; - const popupProps = this.buildPointPopupProps(item, layerKey); - const normalizedSttpMap = popupProps.sttpMap; - const labelText = this.formatPointLabelText( - normalizedSttpMap === 'ylfb' - ? item?.ftp || '' - : item?.titleName || item?.stnm || item?.ennm || '' - ); - const legendState = this.normalizeLegendState( - item?.anchoPointState ? String(item.anchoPointState) : '' - ); - const id = - item?.stcd || item?._id || `${layerKey}-${entityIndex}-${lon}-${lat}`; - - const entity = new Cesium.Entity({ - id: `${layerKey}:${id}`, - position: Cesium.Cartesian3.fromDegrees(lon, lat), - properties: this.createEntityPropertiesBag( - popupProps, - layerKey, - this.getLayerLegendVisibility(layerKey, legendState) - ), - billboard: iconUrl - ? { - image: iconUrl, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - scale: 1 - } - : undefined, - label: labelText - ? { - text: labelText, - font: '12px sans-serif', - style: Cesium.LabelStyle.FILL_AND_OUTLINE, - fillColor: Cesium.Color.WHITE, - outlineColor: Cesium.Color.fromCssColorString('#16324f'), - outlineWidth: 3, - pixelOffset: new Cesium.Cartesian2(0, -26), - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - disableDepthTestDistance: Number.POSITIVE_INFINITY - } - : undefined - }); - - (entity as any).__popupSource = popupProps; - entity.show = this.getLayerLegendVisibility(layerKey, legendState); - this.indexPointEntity(layerKey, legendState, entity); - return entity; - } - - private requestRender() { - this.viewer?.scene.requestRender(); - } - - private setEntityLegendVisible(entity: Cesium.Entity, visible: boolean) { - entity.show = visible; - - if ( - this.viewer && - typeof entity.properties?._legendVisible?.setValue === 'function' - ) { - entity.properties._legendVisible.setValue(visible); - } - } - - private applyLayerLegendVisibility( - layerKey: string, - dataSource?: Cesium.CustomDataSource | null - ) { - const targetDataSource = - dataSource || this.pointLayerRegistry.get(layerKey); - if (!targetDataSource) { - return; - } - - targetDataSource.entities.values.forEach(entity => { - const legendState = this.getEntityLegendState(entity); - this.setEntityLegendVisible( - entity, - this.getLayerLegendVisibility(layerKey, legendState) - ); - }); - } - - private formatPointLabelText(text: string): string { - const normalizedText = String(text || '') - .replace(/[()()]/g, '') - .trim(); - if (!normalizedText) { - return ''; - } - - const maxLineLength = 12; - if (normalizedText.length <= maxLineLength) { - return normalizedText; - } - - const firstLine = normalizedText.slice(0, maxLineLength); - if (normalizedText.length <= maxLineLength * 2) { - return `${firstLine}\n${normalizedText.slice(maxLineLength)}`; - } - - const secondLine = `${normalizedText.slice( - maxLineLength, - maxLineLength + 9 - )}...`; - return `${firstLine}\n${secondLine}`; - } - - private getEntityRuntimeProps(entity: Cesium.Entity) { - if (!this.viewer) { - return {} as Record; - } - - return ( - entity.properties?.getValue?.(this.viewer.clock.currentTime) || - ({} as Record) - ); - } - - private getEntityLabelText(entity: Cesium.Entity) { - if (!this.viewer) { - return ''; - } - - const labelText = entity.label?.text?.getValue?.( - this.viewer.clock.currentTime - ); - return typeof labelText === 'string' ? labelText : ''; - } - - private isEntityEng(entity: Cesium.Entity): boolean { - const props = this.getEntityRuntimeProps(entity); - const layerKey = String(this.getEntityLayerKey(entity) || '').toLowerCase(); - const sttpMap = String( - props._sttpMap || props.sttpMap || props.popupSttpMap || '' - ).toUpperCase(); - const sttpCode = String(props.sttpCode || props.sttp || '').toUpperCase(); - - return ( - layerKey.includes('eng_point') || - sttpMap === 'ENG' || - sttpMap === 'ENG2' || - sttpCode === 'ENG' - ); - } - - private getEntityEngRenderPriority(entity: Cesium.Entity): number { - return this.isEntityEng(entity) ? 200 : 100; - } - - private getEntityExpandOffset(_entity: Cesium.Entity, _currentZoom: number) { - return { - x: 0, - y: 0 - }; - } - - private requestRefreshPointVisibility(_immediate = false, _debounceMs = 0) { - if (!this.viewer) { - return; - } - - this.pointLayerRegistry.forEach((dataSource, layerKey) => { - dataSource.entities.values.forEach(entity => { - const legendState = this.getEntityLegendState(entity); - const visible = - dataSource.show && - this.getLayerLegendVisibility(layerKey, legendState); - this.setEntityLegendVisible(entity, visible); - if (entity.billboard) { - entity.billboard.show = visible; - } - if (entity.label) { - entity.label.show = visible && !!this.getEntityLabelText(entity); - } - }); - }); - - this.requestRender(); - } - private getCurrentCesiumZoom() { - if (!this.viewer) { - return undefined; - } - const height = this.viewer.camera.positionCartographic.height; - if (!height || !isFinite(height) || height <= 0) { - return undefined; - } - const zoom = - this.CESIUM_ZOOM_FORMULA_D + - (this.CESIUM_ZOOM_FORMULA_A - this.CESIUM_ZOOM_FORMULA_D) / - (1 + - Math.pow( - Number(height) / this.CESIUM_ZOOM_FORMULA_C, - this.CESIUM_ZOOM_FORMULA_B - )) + - 1; - - return zoom > -1 ? zoom : 0; - } - - private getCurrentCameraHeight() { - if (!this.viewer) { - return Number.POSITIVE_INFINITY; - } - - const height = this.viewer.camera.positionCartographic.height; - if (!height || !isFinite(height) || height <= 0) { - return Number.POSITIVE_INFINITY; - } - return height; - } - - private getEntityLayerKey(entity: Cesium.Entity) { - if (!this.viewer) { - return ''; - } - const layerKey = entity.properties?._layerKey?.getValue?.( - this.viewer.clock.currentTime - ); - return typeof layerKey === 'string' ? layerKey : ''; - } - - private getEntityLegendState(entity: Cesium.Entity) { - if (!this.viewer) { - return ''; - } - const legendState = entity.properties?.anchoPointState?.getValue?.( - this.viewer.clock.currentTime - ); - return this.normalizeLegendState( - legendState == null ? '' : String(legendState) - ); - } - - private isEntityInteractive(entity: Cesium.Entity) { - const layerKey = this.getEntityLayerKey(entity); - if (!layerKey) { - return false; - } - - const dataSource = this.pointLayerRegistry.get(layerKey); - if (!dataSource || !dataSource.show || entity.show === false) { - return false; - } - - const legendState = this.getEntityLegendState(entity); - return this.getLayerLegendVisibility(layerKey, legendState); - } - - private isHoveredEntityStillVisible() { - if (!this.hoveredEntityId) { - return false; - } - - for (const dataSource of this.pointLayerRegistry.values()) { - const hoveredEntity = dataSource.entities.getById(this.hoveredEntityId); - if (hoveredEntity instanceof Cesium.Entity) { - return this.isEntityInteractive(hoveredEntity); - } - } - - return false; - } - - private hidePopupElement() { - if (!this.popupElement) { - return; - } - - this.popupElement.style.display = 'none'; - } - - private hidePopup() { - this.hoveredEntityId = null; - if (!this.popupElement) { - return; - } - - this.hidePopupElement(); - this.popupElement.innerHTML = ''; - } - - private getPopupHtml(props: Record) { - return ( - props.popupHtml || - generatePopupHtml(props) || - this.buildFallbackPopupHtml(props) - ); - } - - private updatePopupPosition( - position: Cesium.Cartesian3, - entity?: Cesium.Entity - ) { - if (!this.viewer || !this.popupElement) { - return; - } - - const canvasPosition = Cesium.SceneTransforms.worldToWindowCoordinates( - this.viewer.scene, - position - ); - - if (!canvasPosition) { - this.hidePopup(); - return; - } - - const currentZoom = this.getCurrentCesiumZoom(); - const expandOffset = - entity && currentZoom !== undefined - ? this.getEntityExpandOffset(entity, currentZoom) - : { x: 0, y: 0 }; - - this.popupElement.style.left = `${canvasPosition.x + expandOffset.x}px`; - this.popupElement.style.top = `${canvasPosition.y + expandOffset.y}px`; - this.popupElement.style.display = 'block'; - } - - private showPopupForEntity(entity: Cesium.Entity) { - if (!this.viewer || !this.popupElement || !entity.position) { - return; - } - - const currentTime = this.viewer.clock.currentTime; - const rawPopupSource = ((entity as any).__popupSource || {}) as Record< - string, - any - >; - const propertyValue = - entity.properties?.getValue?.(currentTime) || ({} as Record); - const props = { - ...propertyValue, - ...rawPopupSource - }; - - const popupHtml = this.getPopupHtml(props); - const position = entity.position.getValue(currentTime); - if (!popupHtml || !position) { - this.hidePopup(); - return; - } - - this.popupElement.innerHTML = popupHtml; - this.hoveredEntityId = entity.id; - this.updatePopupPosition(position, entity); - } - - private getEntityPopupProps(entity: Cesium.Entity) { - if (!this.viewer) { - return {}; - } - - const currentTime = this.viewer.clock.currentTime; - const rawPopupSource = ((entity as any).__popupSource || {}) as Record< - string, - any - >; - const propertyValue = - entity.properties?.getValue?.(currentTime) || ({} as Record); - - return { - ...propertyValue, - ...rawPopupSource - }; - } - - private bindHoverPopupHandler() { - if (!this.viewer) { - return; - } - - this.viewer.screenSpaceEventHandler.removeInputAction( - Cesium.ScreenSpaceEventType.MOUSE_MOVE - ); - - this.viewer.screenSpaceEventHandler.setInputAction(movement => { - if (!this.viewer) { - return; - } - - const cameraHeight = this.getCurrentCameraHeight(); - if (cameraHeight > this.HOVER_POPUP_MAX_HEIGHT) { - this.hidePopup(); - return; - } - - const picked = this.viewer.scene.pick(movement.endPosition); - const entity = - picked && picked.id instanceof Cesium.Entity ? picked.id : null; - - if (!entity || !this.isEntityInteractive(entity)) { - this.hidePopup(); - return; - } - - if (this.hoveredEntityId === entity.id) { - const position = entity.position?.getValue( - this.viewer.clock.currentTime - ); - if (position) { - this.updatePopupPosition(position, entity); - } - return; - } - - this.showPopupForEntity(entity); - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - } - - private bindPointClickHandler() { - if (!this.viewer) { - return; - } - - this.viewer.screenSpaceEventHandler.removeInputAction( - Cesium.ScreenSpaceEventType.LEFT_CLICK - ); - - this.viewer.screenSpaceEventHandler.setInputAction(movement => { - if (!this.viewer) { - return; - } - - const picked = this.viewer.scene.pick(movement.position); - const entity = - picked && picked.id instanceof Cesium.Entity ? picked.id : null; - - if (!entity || !this.isEntityInteractive(entity)) { - return; - } - - const props = this.getEntityPopupProps(entity); - if (props?.sttpMap === 'ylfb') { - modelStore.ylfbModalVisible = true; - modelStore.params = props; - return; - } - - modelStore.modalVisible = true; - modelStore.params = props; - modelStore.title = props?.titleName || `${props?.stnm || ''} 详情信息`; - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - } - - private bindCameraPopupReset() { - if (!this.viewer) { - return; - } - - if (this.removeCameraChangedListener) { - this.removeCameraChangedListener(); - this.removeCameraChangedListener = null; - } - - this.removeCameraChangedListener = - this.viewer.camera.changed.addEventListener(() => { - if (this.hoveredEntityId) { - this.hidePopup(); - } - }); - } - private showLoadingOverlay(container: HTMLElement) { this.hideLoadingOverlay(); @@ -950,11 +205,7 @@ export class MapCesium implements MapInterface { spinner.style.borderTopColor = '#ffffff'; this.loadingSpinnerAnimation = spinner.animate( [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], - { - duration: 900, - iterations: Infinity, - easing: 'linear' - } + { duration: 900, iterations: Infinity, easing: 'linear' } ); const label = document.createElement('div'); @@ -984,40 +235,326 @@ export class MapCesium implements MapInterface { this.loadingOverlayElement = null; } + // ==================== Step 7: 点击锚点打开详情弹框 ==================== + + private setupClickHandler() { + if (!this.viewer) return; + if (this.clickEventHandler) { + this.clickEventHandler.destroy(); + } + + const modelStore = useModelStore(); + const canvas = this.viewer.scene.canvas; + this.clickEventHandler = new Cesium.ScreenSpaceEventHandler(canvas); + + // 默认箭头光标,与 2D 一致(不显示拖拽小手) + canvas.style.cursor = 'default'; + + // 悬停锚点时显示 pointer + this.clickEventHandler.setInputAction( + (move: { endPosition: Cesium.Cartesian2 }) => { + const scene = this.viewer?.scene; + if (!scene) return; + const picked = scene.pick(move.endPosition); + canvas.style.cursor = + Cesium.defined(picked) && + picked.id && + (picked.id as any).properties?._layerKey + ? 'pointer' + : 'default'; + }, + Cesium.ScreenSpaceEventType.MOUSE_MOVE + ); + + // 点击:先强制渲染一帧确保 scene.pick 不阻塞,然后打开详情弹框 + this.clickEventHandler.setInputAction( + (click: { position: Cesium.Cartesian2 }) => { + const scene = this.viewer?.scene; + if (!scene) return; + + const picked = scene.pick(click.position); + if (!Cesium.defined(picked) || !picked.id) return; + + const entity = picked.id as Cesium.Entity; + const bag = (entity as any).properties; + if (!bag?._layerKey) return; + + // 直接读取原始数据引用,避免 Cesium Property getValue 遍历开销 + const rawData: Record = (entity as any)._rawData || {}; + + if (this.popupElement) { + this.popupElement.style.display = 'none'; + } + + if (rawData.sttpMap === 'ylfb') { + modelStore.ylfbModalVisible = true; + modelStore.params = rawData; + } else { + modelStore.modalVisible = true; + modelStore.params = rawData; + modelStore.title = (rawData.titleName || rawData.stnm) + ' 详情信息'; + } + }, + Cesium.ScreenSpaceEventType.LEFT_CLICK + ); + } + destroy(): void { this.clearFlightFallbackTimer(); this.hideLoadingOverlay(); - this.hidePopup(); + this.destroyAllPointLayers(); + this.pointLayerRegistry.clear(); this.baseLayerRegistry.clear(); this.baseLayerAliasMap.clear(); - this.pointLayerRegistry.clear(); - this.pointLegendVisibility.clear(); - this.pointEntityIndex.clear(); this.imageryBaseLayer = null; + if (this.clickEventHandler) { + this.clickEventHandler.destroy(); + this.clickEventHandler = null; + } + this.popupElement = null; if (this.viewer) { - this.viewer.screenSpaceEventHandler.removeInputAction( - Cesium.ScreenSpaceEventType.MOUSE_MOVE - ); - this.viewer.screenSpaceEventHandler.removeInputAction( - Cesium.ScreenSpaceEventType.LEFT_CLICK - ); - if (this.removeCameraChangedListener) { - this.removeCameraChangedListener(); - this.removeCameraChangedListener = null; - } this.viewer.camera.cancelFlight(); this.viewer.destroy(); this.viewer = null; } this.containerElement = null; - this.popupElement = null; } + private requestRender() { + this.viewer?.scene.requestRender(); + } + + /** + * 标签文本格式化:与 2D formatPointLabelText 完全对齐 + * - 去除括号 ()() + * - 每行最多 12 个字符 + * - 13~24 字符 → 拆成两行 + * - 超过 24 字符 → 第二行末尾用 "..." 截断 + */ + private formatPointLabelText(text: string): string { + const normalizedText = String(text || '') + .replace(/[()()]/g, '') + .trim(); + if (!normalizedText) { + return ''; + } + + const maxLineLength = 12; + if (normalizedText.length <= maxLineLength) { + return normalizedText; + } + + const firstLine = normalizedText.slice(0, maxLineLength); + if (normalizedText.length <= maxLineLength * 2) { + return `${firstLine}\n${normalizedText.slice(maxLineLength)}`; + } + + const secondLine = `${normalizedText.slice( + maxLineLength, + maxLineLength + 9 + )}...`; + return `${firstLine}\n${secondLine}`; + } + + // ==================== Step 2: 基础底图 ==================== + + private getRegistryKeys(layerConfig: any): string[] { + const keys = [layerConfig?.key, layerConfig?.id].filter( + (key): key is string => typeof key === 'string' && key.length > 0 + ); + return Array.from(new Set(keys)); + } + + private resolvePrimaryBaseLayerKey(layerKey: string): string | undefined { + if (!layerKey) return undefined; + if (this.baseLayerRegistry.has(layerKey)) return layerKey; + return this.baseLayerAliasMap.get(layerKey); + } + + private unregisterBaseLayer(layerConfig: any) { + if (!this.viewer) return; + const registryKeys = this.getRegistryKeys(layerConfig); + const uniquePrimaryKeys = new Set(); + + registryKeys.forEach(key => { + const primaryKey = this.resolvePrimaryBaseLayerKey(key) || key; + uniquePrimaryKeys.add(primaryKey); + this.baseLayerAliasMap.delete(key); + }); + + uniquePrimaryKeys.forEach(primaryKey => { + const existingLayer = this.baseLayerRegistry.get(primaryKey); + if (existingLayer) { + this.viewer?.imageryLayers.remove(existingLayer, true); + this.baseLayerRegistry.delete(primaryKey); + } + }); + } + + private registerBaseLayer( + layerConfig: any, + imageryLayer: Cesium.ImageryLayer + ) { + const registryKeys = this.getRegistryKeys(layerConfig); + const primaryKey = layerConfig?.key || layerConfig?.id; + if (!primaryKey) return; + + this.baseLayerRegistry.set(primaryKey, imageryLayer); + registryKeys.forEach(key => { + this.baseLayerAliasMap.set(key, primaryKey); + }); + } + + private getLayerRequestUrl(layerConfig: any): string { + if (layerConfig?.key === 'customBaseLayer') { + return layerConfig?.url_3d; + } + return layerConfig?.url || layerConfig?.url_3d || ''; + } + + private createImageryLayer(layerConfig: any, visible: boolean) { + if (layerConfig?.key === 'hydropBase') return null; + if (!this.viewer) return null; + + const url = this.getLayerRequestUrl(layerConfig); + if (!url) return null; + + let imageryLayer: Cesium.ImageryLayer | null = null; + + // WMTS:与 2D addBaseDataLayer 对齐,从 URL 参数中提取 LAYER / TILEMATRIXSET + // 必须提供 tileMatrixLabels,否则 Cesium 默认用纯数字 tileMatrix 值, + // 而服务器期望的是 "TileMatrixSet:zoom" 格式(如 EPSG:3857_hbb_zrbhq_l13:4) + if (layerConfig?.type === 'wmts') { + const urlParts = url.split('?'); + const baseUrl = urlParts[0]; + const urlParams = new URLSearchParams(urlParts[1] || ''); + const wmtsLayer = urlParams.get('LAYER') || urlParams.get('layer') || ''; + const tileMatrixSetID = + urlParams.get('TILEMATRIXSET') || urlParams.get('TileMatrixSet') || ''; + const maxLevel = 19; + const tileMatrixLabels = Array.from( + { length: maxLevel + 1 }, + (_, i) => `${tileMatrixSetID}:${i}` + ); + + imageryLayer = this.viewer.imageryLayers.addImageryProvider( + new Cesium.WebMapTileServiceImageryProvider({ + url: baseUrl, + layer: wmtsLayer, + style: 'raster', + format: 'image/png', + tileMatrixSetID: tileMatrixSetID, + tileMatrixLabels: tileMatrixLabels, + maximumLevel: maxLevel + }) + ); + } else if ( + layerConfig?.type === 'raster-dem' || + /\{x\}|\{y\}|\{z\}/.test(url) + ) { + imageryLayer = this.viewer.imageryLayers.addImageryProvider( + new Cesium.UrlTemplateImageryProvider({ url }) + ); + } else if (url.includes('MapServer')) { + imageryLayer = this.viewer.imageryLayers.add( + Cesium.ImageryLayer.fromProviderAsync( + Cesium.ArcGisMapServerImageryProvider.fromUrl(url, { + enablePickFeatures: false, + maximumLevel: 19 + }) + ) + ); + } else { + const cleanUrl = url.split('?')[0]; + const wmsLayers = layerConfig?.layers || ''; + imageryLayer = this.viewer.imageryLayers.addImageryProvider( + new Cesium.WebMapServiceImageryProvider({ + url: cleanUrl, + layers: wmsLayers, + parameters: { + service: 'WMS', + transparent: 'true', + format: 'image/png' + } + }) + ); + } + + if (!imageryLayer) return null; + + imageryLayer.show = visible; + if (typeof layerConfig?.rasteropacity === 'number') { + imageryLayer.alpha = layerConfig.rasteropacity; + } + return imageryLayer; + } + + addBaseDataLayer(layerConfig: any, isShow = true): void { + if (!this.viewer || !layerConfig?.key) return; + + // 基地裁切配置存储(与 2D 对齐:layer.type === 'vector' && layer.key === 'hydropBase') + if (layerConfig.type === 'vector' && layerConfig.key === 'hydropBase') { + this.hydropBaseConfig = layerConfig; + return; + } + + if ( + this.IGNORED_BASE_LAYER_KEYS.has(layerConfig.key) || + this.IGNORED_BASE_LAYER_KEYS.has(layerConfig.id) + ) { + return; + } + + if (layerConfig.key === 'BASEMAP-img') { + if (this.imageryBaseLayer) { + this.imageryBaseLayer.show = !!isShow; + } + this.requestRender(); + return; + } + + const imageryLayer = this.createImageryLayer(layerConfig, !!isShow); + if (!imageryLayer) return; + + this.unregisterBaseLayer(layerConfig); + this.registerBaseLayer(layerConfig, imageryLayer); + this.requestRender(); + } + + controlBaseLayerTreeShowAndHidden( + layerType: string, + key: string, + checked: boolean + ): void { + const targetKey = key || layerType; + if (targetKey === 'BASEMAP-img') { + if (this.imageryBaseLayer) { + this.imageryBaseLayer.show = checked; + } + return; + } + + const primaryKey = this.resolvePrimaryBaseLayerKey(targetKey); + if (!primaryKey) return; + + const imageryLayer = this.baseLayerRegistry.get(primaryKey); + if (!imageryLayer) return; + + imageryLayer.show = checked; + this.requestRender(); + } + + hasBaseLayer(layerKey: string): boolean { + if (layerKey === 'BASEMAP-img') return !!this.imageryBaseLayer; + return !!this.resolvePrimaryBaseLayerKey(layerKey); + } + + // ==================== 其他公共方法 ==================== + flyTopanto(position: number[], zoom: number): void { if (!this.viewer || !position) return; const [lng, lat] = position; const height = 20000000 / Math.pow(2, zoom); - this.viewer.camera.cancelFlight(); this.viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees( @@ -1033,199 +570,260 @@ export class MapCesium implements MapInterface { return this.getCurrentCesiumZoom(); } - // ... 其他空实现方法保持不变 ... - jdPanelControlShowAndHidden(_baseid: string, _isAll: boolean): void {} - mdLayerShowOrHidden(): void {} - addBaseDataLayer(layerConfig: any, isShow = true): void { - if (!this.viewer || !layerConfig?.key) { + private getCurrentCesiumZoom() { + if (!this.viewer) return undefined; + const height = this.viewer.camera.positionCartographic.height; + if (!height || !isFinite(height) || height <= 0) return undefined; + const zoom = + this.CESIUM_ZOOM_FORMULA_D + + (this.CESIUM_ZOOM_FORMULA_A - this.CESIUM_ZOOM_FORMULA_D) / + (1 + + Math.pow( + Number(height) / this.CESIUM_ZOOM_FORMULA_C, + this.CESIUM_ZOOM_FORMULA_B + )) + + 1; + return zoom > -1 ? zoom : 0; + } + + zoomToggle(type: 'out' | 'in'): void { + if (!this.viewer) return; + const factor = 1.5; + if (type === 'in') this.viewer.camera.zoomIn(factor); + else this.viewer.camera.zoomOut(factor); + } + + mapOutPut(fileName = '3D_截图.png'): void { + if (!this.viewer) { + console.warn('Viewer is not initialized.'); return; } - - if ( - this.IGNORED_BASE_LAYER_KEYS.has(layerConfig.key) || - this.IGNORED_BASE_LAYER_KEYS.has(layerConfig.id) - ) { - return; - } - - if (layerConfig.key === 'BASEMAP-img') { - if (this.imageryBaseLayer) { - this.imageryBaseLayer.show = !!isShow; + try { + const canvas = this.viewer.canvas; + this.viewer.render(); + const dataURL = canvas.toDataURL('image/png'); + if (dataURL.length < 100) { + console.warn( + 'Canvas data is too small, likely black or empty. Check CORS settings.' + ); } - return; + const link = document.createElement('a'); + link.href = dataURL; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (error) { + console.error('Failed to export map image:', error); } - - const imageryLayer = this.createImageryLayer(layerConfig, !!isShow); - if (!imageryLayer) { - return; - } - - this.unregisterBaseLayer(layerConfig); - this.registerBaseLayer(layerConfig, imageryLayer); } - controlBaseLayerTreeShowAndHidden( - layerType: string, - key: string, - checked: boolean - ): void { - const targetKey = key || layerType; - if (targetKey === 'BASEMAP-img') { - if (this.imageryBaseLayer) { - this.imageryBaseLayer.show = checked; - } - return; - } - const primaryKey = this.resolvePrimaryBaseLayerKey(targetKey); - if (!primaryKey) { - return; - } - - const imageryLayer = this.baseLayerRegistry.get(primaryKey); - if (!imageryLayer) { - return; - } - - imageryLayer.show = checked; - this.requestRender(); + initPopupOverlay(popupContainer: HTMLDivElement): void { + this.popupElement = popupContainer; } - mdLayerTreeShowOrHidden(layerType: string, checked = true): void { - const dataSource = this.pointLayerRegistry.get(layerType); - if (!dataSource) { - return; - } - dataSource.show = checked; - if (!checked) { - this.hidePopup(); - this.requestRender(); - return; - } - this.applyLayerLegendVisibility(layerType, dataSource); - this.requestRefreshPointVisibility(true); - } - hasLayer(layerKey: string): boolean { - return this.pointLayerRegistry.has(layerKey); - } - hasBaseLayer(layerKey: string): boolean { - if (layerKey === 'BASEMAP-img') { - return !!this.imageryBaseLayer; - } - return !!this.resolvePrimaryBaseLayerKey(layerKey); - } - setLegendPointVisible( - layerKey: string, - anchoPointState: string, - checked: boolean - ): void { - const layerStateMap = this.pointLegendVisibility.get(layerKey) || new Map(); - const layerIndex = this.pointEntityIndex.get(layerKey) || new Map(); - const dataSource = this.pointLayerRegistry.get(layerKey); + // ==================== Step 3: 锚点展示 ==================== - if (!anchoPointState) { - layerStateMap.clear(); - layerStateMap.set('__default__', checked); - layerIndex.forEach((_entities, legendKey) => { - layerStateMap.set(legendKey, checked); - }); - this.pointLegendVisibility.set(layerKey, layerStateMap); - if (dataSource) { - this.applyLayerLegendVisibility(layerKey, dataSource); - } - if (!checked) { - this.hidePopup(); - } - this.requestRefreshPointVisibility(true); - return; - } - - const normalizedLegendState = this.normalizeLegendState( - String(anchoPointState) - ); - layerStateMap.set(normalizedLegendState, checked); - this.pointLegendVisibility.set(layerKey, layerStateMap); - - if (dataSource) { - dataSource.entities.values.forEach(entity => { - if (this.getEntityLegendState(entity) === normalizedLegendState) { - this.setEntityLegendVisible(entity, checked); - } - }); - } - if (!checked) { - this.hidePopup(); - } - this.requestRefreshPointVisibility(true); - } addInitDataLayer( - pointData: any[], + pointData: any, layerType: any, _mdoptions?: MDOptions ): void { - const layerKey = String(layerType || ''); - if (!layerKey) { + if (!this.viewer) return; + + let dataArray: any[] = []; + let targetLayerKey = layerType; + + // 与 2D PointLayerManager.addDataLayer 对齐的输入归一化 + if (Array.isArray(pointData)) { + dataArray = pointData; + } else { + dataArray = pointData?.data || []; + targetLayerKey = pointData?.key || layerType; + } + + if (!targetLayerKey) { + console.warn('[Cesium] 缺少图层 Key,无法加载锚点'); return; } - const dataSource = this.getOrCreatePointLayer(layerKey); - if (!dataSource) { - return; - } + // 先移除旧图层数据 + this.removePointLayer(targetLayerKey); - const entityCollection = dataSource.entities as Cesium.EntityCollection & { - suspendEvents?: () => void; - resumeEvents?: () => void; - }; - const canSuspendEntityEvents = - typeof entityCollection?.suspendEvents === 'function' && - typeof entityCollection?.resumeEvents === 'function'; + const entities: Cesium.Entity[] = []; - if (canSuspendEntityEvents) { - entityCollection.suspendEvents(); - } + dataArray.forEach((item: any) => { + const { lgtd, lttd, stnm, iconCode, titleName, ennm } = item; - try { - dataSource.entities.removeAll(); - this.resetPointLayerIndexes(layerKey); + if (lgtd == null || lttd == null) return; - const list = Array.isArray(pointData) ? pointData : []; - list.forEach((item, index) => { - const entity = this.createPointEntity(item, layerKey, index); - if (!entity) { - return; - } - dataSource.entities.add(entity); + const lon = Number(lgtd); + const lat = Number(lttd); + if (isNaN(lon) || isNaN(lat) || !isFinite(lon) || !isFinite(lat)) return; + if (Math.abs(lon) > 180 || Math.abs(lat) > 90) return; + + // 图标:复用现有图标资源路径,与 2D 一致 + const iconUrl = iconCode + ? getIconPath(iconCode) + : getIconPath('default') || ''; + + // 标签文本:格式化(换行截断)+ ylfb 用 ftp,其他用 titleName > stnm > ennm + const rawLabelText = + item.sttpMap === 'ylfb' + ? item.ftp || '' + : titleName || stnm || ennm || ''; + const labelText = this.formatPointLabelText(rawLabelText); + // 行数决定纵向偏移量,与 2D getPointLabelRenderOffsetY 对齐 + const labelLineCount = labelText.includes('\n') ? 2 : labelText ? 1 : 0; + + // Entity ID:与 2D 对齐,使用 _id 优先(_id = sttpMap_stcd),确保同 stcd 不同 sttpMap 的点不互相覆盖 + const rawId = + item._id || item.stcd || `${lon.toFixed(6)},${lat.toFixed(6)}`; + const entityId = `${targetLayerKey}:${rawId}`; + + // 图标缩放:使用 CallbackProperty 动态响应相机缩放变化 + // 调参见顶部 ICON_ 常量 + const dynamicIconScale = new Cesium.CallbackProperty(() => { + const zoom = this.getCurrentCesiumZoom() || 4.5; + return Math.max( + this.ICON_MIN_SCALE, + Math.min( + this.ICON_MAX_SCALE, + this.ICON_BASE_SCALE + (zoom - 4.5) * this.ICON_SCALE_RATE + ) + ); + }, false); + + // 字体大小:与 2D getPointFontSize 对齐 → fontSize = clamp(base * scale, min, max) + // 调参见顶部 LABEL_FONT_ 常量 + const dynamicLabelFont = new Cesium.CallbackProperty(() => { + const zoom = this.getCurrentCesiumZoom() || 4.5; + const scale = Math.max(0.5, Math.min(3.0, 0.7 + (zoom - 4.5) * 0.08)); + const fontSize = Math.round( + Math.max( + this.LABEL_FONT_MIN, + Math.min(this.LABEL_FONT_MAX, this.LABEL_FONT_BASE * scale) + ) + ); + return `${fontSize}px sans-serif`; + }, false); + + // 标签纵向偏移:与 2D getPointLabelRenderOffsetY 对齐 + // 调参见顶部 LABEL_OFFSET_ 常量 + const dynamicLabelOffset = new Cesium.CallbackProperty(() => { + const zoom = this.getCurrentCesiumZoom() || 4.5; + const scale = Math.max(0.5, Math.min(3.0, 0.7 + (zoom - 4.5) * 0.08)); + const offsetY = + labelLineCount > 1 + ? -this.LABEL_OFFSET_MULTI * scale + : -this.LABEL_OFFSET_SINGLE * scale; + return new Cesium.Cartesian2(0, offsetY); + }, false); + + const entity = new Cesium.Entity({ + id: entityId, + position: Cesium.Cartesian3.fromDegrees(lon, lat, 0), + billboard: { + image: iconUrl || undefined, + scale: dynamicIconScale as any, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + scaleByDistance: new Cesium.NearFarScalar(1.0e3, 1.5, 2.0e7, 0.8), + disableDepthTestDistance: Number.POSITIVE_INFINITY + } as any, + label: { + text: labelText || undefined, + font: dynamicLabelFont as any, + fillColor: Cesium.Color.WHITE, + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + style: Cesium.LabelStyle.FILL_AND_OUTLINE, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + pixelOffset: dynamicLabelOffset as any, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + scaleByDistance: new Cesium.NearFarScalar(1.0e3, 1.2, 2.0e7, 0.8), + disableDepthTestDistance: Number.POSITIVE_INFINITY + } as any, + properties: { + ...item, + _layerKey: targetLayerKey, + _iconUrl: iconUrl, + _labelText: labelText, + _legendVisible: true, + _regionVisible: true, + _sttpMap: item.sttpMap + } as any }); - } finally { - if (canSuspendEntityEvents) { - entityCollection.resumeEvents?.(); - } - } - dataSource.show = true; - this.applyLayerLegendVisibility(layerKey, dataSource); - this.requestRefreshPointVisibility(true); + this.viewer!.entities.add(entity); + // 存储原始业务数据引用,避免点击时遍历 Cesium Property + (entity as any)._rawData = item; + // 可见性控制标志(组合计算 entity.show) + (entity as any)._layerVisible = true; + (entity as any)._legendVisible = true; + (entity as any)._regionVisible = true; + entities.push(entity); + }); + + this.pointLayerRegistry.set(targetLayerKey, entities); + + // 如果当前有活跃的基地裁切,新图层也需要过滤 + if (this.currentClipGeoJson) { + this.filterPointsByRegionForLayer( + targetLayerKey, + this.currentClipGeoJson + ); + } } + + mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void { + const entities = this.pointLayerRegistry.get(layerType); + if (!entities) return; + + const visible = checked !== false; + entities.forEach(entity => { + (entity as any)._layerVisible = visible; + this.applyEntityVisibility(entity); + }); + } + removePointLayer(layerKey: string): void { - if (!this.viewer) { - return; - } + if (!layerKey) return; - const dataSource = this.pointLayerRegistry.get(layerKey); - if (!dataSource) { - return; - } + const entities = this.pointLayerRegistry.get(layerKey); + if (!entities) return; - this.viewer.dataSources.remove(dataSource, true); + entities.forEach(entity => { + if (this.viewer && !this.viewer.isDestroyed()) { + this.viewer.entities.remove(entity); + } + }); this.pointLayerRegistry.delete(layerKey); - this.pointLegendVisibility.delete(layerKey); - this.pointEntityIndex.delete(layerKey); - this.hidePopup(); - this.requestRender(); } - switchView(_type: any): void {} - fitBounds(_bounds: any): void {} + + hasLayer(layerKey: string): boolean { + return this.pointLayerRegistry.has(layerKey); + } + + private destroyAllPointLayers(): void { + this.pointLayerRegistry.forEach((entities, layerKey) => { + entities.forEach(entity => { + if (this.viewer && !this.viewer.isDestroyed()) { + this.viewer.entities.remove(entity); + } + }); + }); + this.pointLayerRegistry.clear(); + } + + // ==================== Stubs(后续步骤补齐) ==================== + baseLayerSwitcher(_key: string): void {} + switchView(_type: '2D' | '3D'): void {} + fitBounds(_bounds: any): void {} + lengthCalculate(): void {} + areCalculate(): void {} + removeQueryLayer(): void {} addTertiarybasinLayer( _layer: layer, _fillcolor: any, @@ -1233,79 +831,368 @@ export class MapCesium implements MapInterface { _datas: any ): void {} hideTertiarybasinLayer(_layer: layer): void {} - zoomToggle(type: 'out' | 'in'): void { - if (!this.viewer) return; - const factor = 1.5; - if (type === 'in') this.viewer.camera.zoomIn(factor); - else this.viewer.camera.zoomOut(factor); - } - lengthCalculate(): void {} - areCalculate(): void {} - removeQueryLayer(): void {} - // src/components/gis/map.cesium.ts - - /** - * 地图截图下载 - * @param fileName 可选,下载的文件名,默认为 'cesium_map.png' - */ - mapOutPut(fileName = 'cesium_map.png'): void { - if (!this.viewer) { - console.warn('Viewer is not initialized.'); + async jdPanelControlShowAndHidden( + _regionId: string, + isAll: boolean + ): Promise { + if (!this.viewer || !this.hydropBaseConfig) { + console.warn('地图未初始化或 hydropBaseConfig 未配置'); return; } + // 取消正在进行的裁切请求 + if (this.clipRequestController) { + this.clipRequestController.abort(); + this.clipRequestController = null; + } + + this.BASEID = _regionId; + + if (!isAll) { + // 取消裁切:清除遮罩多边形,恢复全部锚点可见,平滑飞回全国视图 + this.currentClipGeoJson = null; + this.clearMaskPolygon(); + this.setAllPointsRegionVisible(true); + this.viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees( + this.CHINA_CENTER.lng, + this.CHINA_CENTER.lat, + this.CHINA_DEFAULT_HEIGHT + ), + orientation: this.CHINA_VIEW_ORIENTATION, + duration: 1.0 + }); + return; + } + + // 请求前先隐藏全部锚点(避免 API 响应前锚点短暂错误显示) + this.setAllPointsRegionVisible(false); + + this.clipRequestController = new AbortController(); + const signal = this.clipRequestController.signal; + try { - const canvas = this.viewer.canvas; + const url = + this.hydropBaseConfig.geojson_url + + `&cql_filter=BASEID='${this.BASEID}'`; - // ✅ 关键步骤 1: 强制渲染一帧,确保画布内容是最新的 - // 这有助于解决因缓冲区清除导致的黑屏问题 - this.viewer.render(); + const geoJsonData = await this.fetchGeoJson(url, signal); - // ✅ 关键步骤 2: 尝试获取数据 - // 注意:如果存在跨域污染,这里可能会抛出 SecurityError - const dataURL = canvas.toDataURL('image/png'); + if (signal.aborted) return; + if (this.BASEID !== _regionId) return; - // 检查是否获取到了有效数据(简单的长度检查) - if (dataURL.length < 100) { - console.warn( - 'Canvas data is too small, likely black or empty. Check CORS settings.' - ); - // 可以尝试 fallback 到 blob 方式,但通常 CORS 问题两者都会失败 - } - - // 创建临时链接元素 - const link = document.createElement('a'); - link.href = dataURL; - link.download = fileName; - - // 触发点击事件进行下载 - document.body.appendChild(link); - link.click(); - - // 清理临时元素 - document.body.removeChild(link); - } catch (error) { - console.error('Failed to export map image:', error); - if (error instanceof DOMException && error.name === 'SecurityError') { - console.error( - 'Security Error: The canvas has been tainted by cross-origin data. Ensure all imagery providers support CORS.' - ); + this.currentClipGeoJson = geoJsonData; + this.filterPointsByRegion(geoJsonData); + this.fitViewToGeoJson(geoJsonData); + this.createMaskPolygon(geoJsonData); + } catch (error: any) { + if (error.name === 'AbortError') return; + this.currentClipGeoJson = null; + console.error('加载裁切数据失败:', error); + this.clearMaskPolygon(); + this.setAllPointsRegionVisible(true); + } finally { + if (this.clipRequestController?.signal === signal) { + this.clipRequestController = null; } } } + mdLayerShowOrHidden( + _layerType: string, + _key?: string, + _baseid?: string, + _checked?: boolean, + _isAll?: boolean + ): void {} + setLegendPointVisible( + layerKey: string, + anchoPointState: string, + checked: boolean + ): void { + const entities = this.pointLayerRegistry.get(layerKey); + if (!entities) return; - // ... 其他代码 ... - initPopupOverlay(popupContainer: HTMLDivElement): void { - this.popupElement = popupContainer; - this.popupElement.style.display = 'none'; - this.popupElement.style.position = 'absolute'; - this.popupElement.style.transform = 'translate(-50%, calc(-100% - 10px))'; - this.popupElement.style.pointerEvents = 'none'; - if ( - this.containerElement && - getComputedStyle(this.containerElement).position === 'static' - ) { - this.containerElement.style.position = 'relative'; + if (!anchoPointState) { + // 第一步:全部隐藏(配合 restoreLayerLegendVisibility 两步走:先全隐藏再按图例逐项恢复) + entities.forEach(entity => { + (entity as any)._legendVisible = false; + this.applyEntityVisibility(entity); + }); + return; } + + // 按 anchoPointState 字段匹配并设置可见性 + entities.forEach(entity => { + const rawData = (entity as any)._rawData; + if (rawData && rawData.anchoPointState === anchoPointState) { + (entity as any)._legendVisible = checked; + this.applyEntityVisibility(entity); + } + }); + } + + // ==================== 基地裁切辅助方法 ==================== + + /** 获取 GeoJSON 数据 */ + private async fetchGeoJson(url: string, signal?: AbortSignal): Promise { + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + } + + /** 批量设置全部锚点的 _regionVisible 标志 */ + private setAllPointsRegionVisible(visible: boolean): void { + this.pointLayerRegistry.forEach(entities => { + entities.forEach(entity => { + (entity as any)._regionVisible = visible; + this.applyEntityVisibility(entity); + }); + }); + } + + /** 根据 GeoJSON 边界批量更新锚点的区域显隐状态(射线法) */ + private filterPointsByRegion(geoJson: any): void { + const regionCoords = this.extractPolygonCoords(geoJson); + if (!regionCoords.length) { + console.warn('无法解析区域边界,显示所有锚点'); + this.setAllPointsRegionVisible(true); + return; + } + + this.pointLayerRegistry.forEach((_entities, layerKey) => { + this.filterPointsByRegionForLayer(layerKey, geoJson); + }); + } + + /** 对单个图层应用基地裁切过滤 */ + private filterPointsByRegionForLayer(layerKey: string, geoJson: any): void { + const entities = this.pointLayerRegistry.get(layerKey); + if (!entities) return; + + const regionCoords = this.extractPolygonCoords(geoJson); + if (!regionCoords.length) return; + + entities.forEach(entity => { + const rawData = (entity as any)._rawData; + if (!rawData) { + (entity as any)._regionVisible = false; + this.applyEntityVisibility(entity); + return; + } + + const lon = Number(rawData.lgtd); + const lat = Number(rawData.lttd); + + if (!isFinite(lon) || !isFinite(lat)) { + (entity as any)._regionVisible = false; + this.applyEntityVisibility(entity); + return; + } + + (entity as any)._regionVisible = this.isPointInPolygon( + lon, + lat, + regionCoords + ); + this.applyEntityVisibility(entity); + }); + } + + /** 根据 GeoJSON 范围飞行相机到基地边界 */ + private fitViewToGeoJson(geoJson: any): void { + if (!this.viewer) return; + + try { + const coords = this.extractPolygonCoords(geoJson); + if (!coords.length) return; + + // 计算所有坐标的经纬度范围 + let minLon = Infinity; + let maxLon = -Infinity; + let minLat = Infinity; + let maxLat = -Infinity; + + coords.forEach(ring => { + ring.forEach(([lon, lat]) => { + if (lon < minLon) minLon = lon; + if (lon > maxLon) maxLon = lon; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + }); + }); + + if (!isFinite(minLon)) return; + + const rectangle = Cesium.Rectangle.fromDegrees( + minLon, + minLat, + maxLon, + maxLat + ); + + this.viewer.camera.flyTo({ + destination: rectangle, + duration: 1.0, + complete: () => this.requestRender() + }); + } catch (error) { + console.error('调整3D视野失败:', error); + } + } + + /** 应用基地裁切:globe.clippingPolygons 直接裁切地球渲染 */ + private createMaskPolygon(geoJson: any): void { + if (!this.viewer) return; + + this.clearMaskPolygon(); + + 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 层面裁切地球,保留基地边界内部,外部不渲染 + this.viewer.scene.globe.clippingPolygons = + new Cesium.ClippingPolygonCollection({ + polygons: cartesianRings.map( + positions => new Cesium.ClippingPolygon({ positions }) + ), + inverse: true, + 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' }, + { width: 1.5, color: '#CCC9F4' } + ]; + borderStyles.forEach(({ width, color }) => { + cartesianRings.forEach(positions => { + this.maskPolygonEntities.push( + this.viewer!.entities.add({ + polyline: { + positions, + width, + material: Cesium.Color.fromCssColorString(color), + clampToGround: true + } + }) + ); + }); + }); + + this.requestRender(); + this.viewer.scene.screenSpaceCameraController.maximumZoomDistance = 6_400_000; + } + + /** 清除遮罩多边形 */ + private clearMaskPolygon(): void { + if (!this.viewer) return; + + // 解除 globe 裁切 + this.viewer.scene.globe.clippingPolygons = + new Cesium.ClippingPolygonCollection(); + + // 恢复背景色、星空、大气层 + if (this.originalBgColor) { + this.viewer.scene.backgroundColor = this.originalBgColor; + this.originalBgColor = null; + } + if (this.viewer.scene.skyBox) { + this.viewer.scene.skyBox.show = this.skyBoxShown; + } + if (this.viewer.scene.skyAtmosphere) { + this.viewer.scene.skyAtmosphere.show = this.skyAtmosphereShown; + } + + this.viewer.scene.screenSpaceCameraController.maximumZoomDistance = + Infinity; + + this.maskPolygonEntities.forEach(entity => { + if (!this.viewer!.isDestroyed()) { + this.viewer!.entities.remove(entity); + } + }); + this.maskPolygonEntities = []; + } + + /** 从 GeoJSON 中提取多边形环坐标 */ + private extractPolygonCoords(geoJson: any): number[][][] { + if (!geoJson?.features?.length) return []; + + const coords: number[][][] = []; + geoJson.features.forEach((feature: any) => { + const geometry = feature.geometry; + if (!geometry) return; + + if (geometry.type === 'Polygon') { + coords.push(...geometry.coordinates); + } else if (geometry.type === 'MultiPolygon') { + geometry.coordinates.forEach((polygon: number[][][]) => { + coords.push(...polygon); + }); + } + }); + + return coords; + } + + /** 射线法判断点是否在任意一个多边形环内 */ + private isPointInPolygon( + lon: number, + lat: number, + polygons: number[][][] + ): boolean { + for (const polygon of polygons) { + if (this.isPointInRing(lon, lat, polygon)) { + return true; + } + } + return false; + } + + /** 射线法判断点是否在单个闭合环内 */ + private isPointInRing(lon: number, lat: number, ring: number[][]): boolean { + let inside = false; + const n = ring.length; + + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = ring[i][0]; + const yi = ring[i][1]; + const xj = ring[j][0]; + const yj = ring[j][1]; + + const intersect = + yi > lat !== yj > lat && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + + if (intersect) inside = !inside; + } + + return inside; + } + + /** 组合计算 entity.show:layerVisible && legendVisible && regionVisible */ + private applyEntityVisibility(entity: Cesium.Entity): void { + const e = entity as any; + entity.show = e._layerVisible && e._legendVisible && e._regionVisible; } } diff --git a/frontend/src/components/gis/map.ol.ts b/frontend/src/components/gis/map.ol.ts index db4a9528..5b7db0c7 100644 --- a/frontend/src/components/gis/map.ol.ts +++ b/frontend/src/components/gis/map.ol.ts @@ -381,7 +381,7 @@ export class MapOl implements MapInterface { legendVisible !== false && regionVisible !== false && densityVisible && - this.shouldRenderNearbyFeature(feature, currentZoom); + this.shouldRenderNearbyFeature(); const dynamicScale = this.getDynamicPointScale(currentZoom); const fontSize = this.getPointFontSize(dynamicScale); @@ -706,7 +706,7 @@ export class MapOl implements MapInterface { legendVisible: feature.get('_legendVisible') !== false, regionVisible: feature.get('_regionVisible') !== false, densityPriority: this.getFeatureDensityPriority(feature), - nearbyVisible: this.shouldRenderNearbyFeature(feature, currentZoom), + nearbyVisible: this.shouldRenderNearbyFeature(), labelText: this.formatPointLabelText( feature.get('_labelText') as string ), @@ -1396,7 +1396,27 @@ export class MapOl implements MapInterface { ); } + private isFeatureEngAlarm(feature: Feature): boolean { + const layerKey = String( + feature.get('_layerKey') || feature.get('type') || '' + ).toLowerCase(); + const legendState = String(feature.get('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_') + ); + } + private getFeatureEngRenderPriority(feature: Feature): number { + if (this.isFeatureEngAlarm(feature)) { + return 300; + } return this.isFeatureEng(feature) ? 200 : 100; } @@ -1472,52 +1492,10 @@ export class MapOl implements MapInterface { } /** - * 统一根据近邻点分组和缩放阶段判断当前要素是否应该参与渲染。 + * 近邻点统一参与原位堆叠,具体谁显示在顶层交给碰撞排序和图层优先级控制。 */ - private shouldRenderNearbyFeature( - feature: Feature, - currentZoom: number - ): boolean { - const nearbyGroupId = feature.get('_nearbyGroupId'); - const groupSize = Number(feature.get('_nearbyGroupSize')); - const priority = Number(feature.get('_nearbyPriority')); - const showZoomMin = feature.get('_nearbyShowZoomMin'); - const showZoomMax = feature.get('_nearbyShowZoomMax'); - const expandAtZoom = feature.get('_nearbyExpandAtZoom'); - - if (!nearbyGroupId || !Number.isFinite(groupSize) || groupSize < 2) { - return true; - } - - if (!Number.isFinite(priority) || priority <= 0) { - return true; - } - - if ( - showZoomMin !== null && - showZoomMin !== undefined && - currentZoom < Number(showZoomMin) - ) { - return false; - } - - if ( - showZoomMax !== null && - showZoomMax !== undefined && - currentZoom > Number(showZoomMax) - ) { - return false; - } - - if ( - expandAtZoom !== null && - expandAtZoom !== undefined && - currentZoom >= Number(expandAtZoom) - ) { - return true; - } - - return priority === 1; + private shouldRenderNearbyFeature(): boolean { + return true; } /** * 初始化加载基础图层 diff --git a/frontend/src/components/gis/ol/point-layer-manager.ts b/frontend/src/components/gis/ol/point-layer-manager.ts index b448288f..87e29dd7 100644 --- a/frontend/src/components/gis/ol/point-layer-manager.ts +++ b/frontend/src/components/gis/ol/point-layer-manager.ts @@ -5,8 +5,6 @@ import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import { fromLonLat } from 'ol/proj'; import { getIconPath } from '@/utils/index'; -import { getNearbyPointConfig } from '@/modules/map/domain/nearby-point-rules'; - type PointLayerStyleFactory = (feature: Feature) => any; type PointLayerManagerOptions = { @@ -80,103 +78,12 @@ export class PointLayerManager { updateNearbyFeatureLayout(currentZoom?: number): void { if (!this.map) return; - const resolution = this.map.getView().getResolution(); - if ( - currentZoom === undefined || - !Number.isFinite(currentZoom) || - !resolution - ) { + if (currentZoom === undefined || !Number.isFinite(currentZoom)) { return; } - const nearbyPointConfig = getNearbyPointConfig(); - const { autoRule, layoutRule } = nearbyPointConfig; - - const groupMap = new Map(); this.forEachFeature(feature => { this.resetFeatureToBaseCoordinate(feature); - - const groupId = feature.get('_nearbyGroupId'); - const groupSize = Number(feature.get('_nearbyGroupSize')); - if (!groupId || !Number.isFinite(groupSize) || groupSize < 2) { - return; - } - - const groupFeatures = groupMap.get(groupId) || []; - groupFeatures.push(feature); - groupMap.set(groupId, groupFeatures); - }); - - groupMap.forEach(features => { - const expandAtZoom = Number(features[0]?.get('_nearbyExpandAtZoom')); - if (!Number.isFinite(expandAtZoom) || currentZoom < expandAtZoom) { - return; - } - const sortedFeatures = [...features].sort((left, right) => { - return ( - Number(left.get('_nearbyPriority') || 0) - - Number(right.get('_nearbyPriority') || 0) - ); - }); - const secondaryFeatures = sortedFeatures.filter(feature => { - const priority = Number(feature.get('_nearbyPriority')); - return Number.isFinite(priority) && priority > 1; - }); - - secondaryFeatures.forEach((feature, index) => { - const baseCoordinates = feature.get('_baseCoordinates') as - | number[] - | undefined; - const geometry = feature.getGeometry(); - if ( - !baseCoordinates || - baseCoordinates.length < 2 || - !geometry || - geometry.getType() !== 'Point' - ) { - return; - } - - const groupSecondaryCount = secondaryFeatures.length; - const baseOffsetPx = - Number(feature.get('_nearbyExpandOffsetPx')) || - autoRule.expandOffsetPx; - const ringSize = Math.max(1, layoutRule.ringSize); - const ringIndex = Math.floor(index / ringSize); - const ringOffsetIndex = index % ringSize; - const currentRingCount = Math.min( - ringSize, - groupSecondaryCount - ringIndex * ringSize - ); - const fanAngleDeg = - currentRingCount <= 1 - ? 0 - : currentRingCount <= 2 - ? layoutRule.fanAngleForTwoDeg - : currentRingCount <= 4 - ? layoutRule.fanAngleForFourDeg - : layoutRule.fanAngleForManyDeg; - const angleStepDeg = - currentRingCount <= 1 ? 0 : fanAngleDeg / (currentRingCount - 1); - const startAngleDeg = -90 - fanAngleDeg / 2; - const expandAngleDeg = startAngleDeg + ringOffsetIndex * angleStepDeg; - const expandAngleRad = (expandAngleDeg * Math.PI) / 180; - const expandOffsetPx = - baseOffsetPx + - ringIndex * - Math.max( - layoutRule.ringGapPx, - baseOffsetPx * layoutRule.ringGapFactor - ); - const offsetX = Math.cos(expandAngleRad) * expandOffsetPx * resolution; - const offsetY = Math.sin(expandAngleRad) * expandOffsetPx * resolution; - - feature.set('_nearbyExpandAngleDeg', expandAngleDeg, true); - (geometry as Point).setCoordinates([ - baseCoordinates[0] + offsetX, - baseCoordinates[1] - offsetY - ]); - }); }); } @@ -392,7 +299,6 @@ export class PointLayerManager { if (!isFinite(coord[0]) || !isFinite(coord[1])) { return null; } - let iconUrl = iconCode ? getIconPath(iconCode) : ''; if (!iconUrl) { iconUrl = getIconPath('default') || ''; @@ -403,7 +309,9 @@ export class PointLayerManager { ...item, _layerKey: targetLayerKey }); - feature.setId(item.stcd || item._id); + const rawFeatureId = + item.stcd || item._id || `${lon.toFixed(6)},${lat.toFixed(6)}`; + feature.setId(`${targetLayerKey}:${rawFeatureId}`); feature.set('_iconUrl', iconUrl); feature.set( '_labelText', @@ -457,7 +365,6 @@ export class PointLayerManager { const valueMap = fieldIndexMap.get(fieldKey); if (!valueMap) return []; - return valueMap.get(String(matchValue)) || []; } diff --git a/frontend/src/components/mapFilter/index.vue b/frontend/src/components/mapFilter/index.vue index c6cf8ef8..410568de 100644 --- a/frontend/src/components/mapFilter/index.vue +++ b/frontend/src/components/mapFilter/index.vue @@ -1,8 +1,5 @@