添加栖息地锚点hover效果,优化3d加载,漫游加载

This commit is contained in:
扈兆增 2026-08-06 15:57:46 +08:00
parent 66f90555ff
commit a948f0bb8d
12 changed files with 499 additions and 145 deletions

View File

@ -244,7 +244,7 @@ const getFtpOptions = async () => {
//
const refreshTable = () => {
const year = searchParams.value.tm;
const year = searchParams.value.tm || new Date().getFullYear().toString();
const startDate = `${year}-01-01 00:00:00`;
const endDate = `${year}-12-31 23:59:59`;

View File

@ -352,8 +352,6 @@ export const setMapLegendPos = (
const hasRight = rightList?.length > 0;
const hasBottom = bottomList?.length > 0;
console.log('[图例] hasLeft:', hasLeft, 'hasBottom:', hasBottom, 'bottomRowHeight:', bottomRowHeight);
const w = `${offset}px`;
const l = `${_theme === 'ly-8' ? (menuState ? 643 : 510) : offset}px`;
let b = `0px`;

View File

@ -13,7 +13,6 @@ import {
osgbLocation,
type OSGBItem
} from './osgbUtils';
export class MapCesium implements MapInterface {
private viewer: Cesium.Viewer | null = null;
private _ready = false;
@ -78,12 +77,10 @@ export class MapCesium implements MapInterface {
private _currentPitchMode: 'down' | 'flat' = 'down';
/** 漫游模式:强制锚点文字/图标开启深度测试,避免文字遮挡漫游模型 */
private _isRoaming = false;
/** 漫游已开始但模型尚未就绪(待 activateRoamingPopups 触发批量 popup */
private _pendingRoamingPopups = false;
/** 漫游中锚点地形预热的 rAF 句柄hover 依赖 billboard 贴地才能被 drillPick 命中 */
private _warmAnchorTerrainRafId: number | null = null;
/** 重建批量 popup 时连续空候选次数(防御:避免瞬间过滤导致全屏 Popup 消失) */
private _emptyCandidateCount = 0;
/** 漫游批量模式空容器被动重建的节流时间戳 */
private _lastRoamPassiveRebuild = 0;
/** 碰撞检测防重入标志,避免级联触发导致重复执行 */
private _collisionBusy = false;
/** 批量初始化锚点图层标志flushPendingInitLayers 批量加载时合并碰撞检测,避免 O(N²) 全量重算 */
@ -160,7 +157,7 @@ export class MapCesium implements MapInterface {
async init(container: HTMLElement, _rectangle?: any): Promise<any> {
try {
this.containerElement = container;
const token = 'bearer b734a443-2c8f-4f4a-8698-44828cc5f709';
const token = 'bearer 1404a7e0-3bbf-4435-b864-6d71f509b29d';
this.viewer = new Cesium.Viewer(container, {
animation: false,
@ -462,13 +459,6 @@ export class MapCesium implements MapInterface {
const scene = this.viewer?.scene;
if (!scene) return;
// 漫游批量模式下完全禁用 hover所有锚点由批量 popup 固定展示,避免与漫游/模型交互冲突
if (this.isBatchPopupMode && this._isRoaming) {
canvas.style.cursor = 'default';
this.hidePopup();
return;
}
const cameraHeight = this.getCurrentCameraHeight();
// 缩放门槛:中国视角以上不触发 hover
if (cameraHeight > this.HOVER_POPUP_MAX_HEIGHT) {
@ -626,15 +616,8 @@ export class MapCesium implements MapInterface {
this.refreshEntityPositionsWithTerrain();
if (this._isRoaming) {
if (this._pendingRoamingPopups) {
// 漫游模型尚未就绪activateRoamingPopups 未触发),跳过提前开启批量 Popup
} else if (!this.isBatchPopupMode) {
// 漫游中:批量 popup 无条件开启(不受缩放阈值限制)
this.enableBatchPopupMode(true);
} else {
// 漫游中:无条件重建
this.rebuildBatchPopupsIfNeeded();
}
// 漫游中不启用固定批量 popup只 hover 展示),保持批量模式关闭;
// 退出漫游后由下方 zoom 分支按缩放级别恢复
} else if (zoom >= this.BATCH_POPUP_MODE_ZOOM) {
if (!this.isBatchPopupMode) {
this.enableBatchPopupMode();
@ -681,20 +664,69 @@ export class MapCesium implements MapInterface {
setRoamDepthTest(enabled: boolean): void {
this._isRoaming = enabled;
this.applyPointDepthTest(this.getDepthTestValue());
// 漫游开始时仅记录“待开启”标志,不在模型加载/相机飞到位前直接开启批量 popup
//(避免旧视角锚点 Popup 提前全部出现)。由 activateRoamingPopups() 在模型渲染就绪后真正开启;
// 退出漫游时清除标志,批量模式恢复由 moveEnd/postRender 的缩放逻辑接管。
this._pendingRoamingPopups = enabled;
if (enabled) {
// 漫游中不启用固定批量 popup只保留 hover 展示:
// 若进入漫游前批量模式已开启(缩放级别较高),先移除固定 popup
if (this.isBatchPopupMode) {
this.disableBatchPopupMode();
}
} else {
// 退出漫游:取消锚点地形预热轮询,避免 rAF 泄漏;批量模式由 moveEnd/缩放逻辑恢复
if (this._warmAnchorTerrainRafId !== null) {
cancelAnimationFrame(this._warmAnchorTerrainRafId);
this._warmAnchorTerrainRafId = null;
}
}
}
/**
* ThreeDRoamManager/
* enableBatchPopupMode(true) Popup
* ThreeDRoamManager/
* popup hover
* hover drillPick billboard
*/
activateRoamingPopups(): void {
if (!this._isRoaming || !this._pendingRoamingPopups) return;
this._pendingRoamingPopups = false;
this.enableBatchPopupMode(true);
if (!this._isRoaming) return;
this.warmAnchorTerrainForHover();
}
/**
*
* billboard dynamicPosition globe.getHeight 退1
* drillPick hover
*/
private warmAnchorTerrainForHover(): void {
if (!this.viewer) return;
if (this._warmAnchorTerrainRafId !== null) return;
let frames = 0;
const check = () => {
this._warmAnchorTerrainRafId = null;
if (!this.viewer || !this._isRoaming) return;
frames++;
// ~10s 封顶:避免个别锚点瓦片长时间不返回导致 rAF 空转
if (frames >= 600 || this.areAllAnchorsTerrainResolved()) return;
this._warmAnchorTerrainRafId = requestAnimationFrame(check);
};
this._warmAnchorTerrainRafId = requestAnimationFrame(check);
}
/** 所有锚点位置的地形高度是否已解析(未解析时调用 getHeight 会顺带触发瓦片请求) */
private areAllAnchorsTerrainResolved(): boolean {
const viewer = this.viewer;
const globe = viewer?.scene.globe;
if (!globe) return true;
if (this.pointLayerRegistry.size === 0) return true;
const time = viewer!.clock.currentTime;
for (const entities of this.pointLayerRegistry.values()) {
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (!entity?.position) continue;
const pos = entity.position.getValue(time);
if (!pos) continue;
const carto = Cesium.Cartographic.fromCartesian(pos);
if (globe.getHeight(carto) === undefined) return false;
}
}
return true;
}
/** 当前应使用的深度测试值flat平视/漫游)→ 0 启用深度测试让模型可遮挡down俯视→ 禁用避免地形遮挡文字 */
@ -789,6 +821,8 @@ export class MapCesium implements MapInterface {
private isEntityInteractive(entity: Cesium.Entity): boolean {
const e = entity as any;
if (e._isWfs) return false; // WFS 河段实体走独立高亮逻辑,不参与锚点交互
// 漫游模型CZML 飞机/路径)不作为可交互锚点:避免 hover/点击把模型当锚点处理
if (this._isRoaming && this.viewer?.trackedEntity === entity) return false;
if (e._layerVisible === false) return false;
if (e._legendVisible === false) return false;
if (e._regionVisible === false) return false;
@ -1046,7 +1080,8 @@ export class MapCesium implements MapInterface {
samplePoint.y = y;
const picked = scene.pickPosition(samplePoint);
if (!Cesium.defined(picked)) return true;
return Cesium.Cartesian3.distance(cameraPos, picked) + 10 >= posDist;
const pickedDist = Cesium.Cartesian3.distance(cameraPos, picked);
return pickedDist + 10 >= posDist;
};
// 快速路径:图标中心仍可见 → 直接判定未遮挡(最常见情况,仅 1 次拾取)
@ -1244,8 +1279,8 @@ export class MapCesium implements MapInterface {
)
return;
// 被地形遮挡的不展示 popup漫游时跳过模型上方锚点易被新加载的高精度地形误判为遮挡
if (!this._isRoaming && this.isPositionBehindTerrain(worldPos)) return;
// 被地形/模型遮挡的不展示 popup漫游时同样检测锚点被遮挡 popup 一起消失
if (this.isPositionBehindTerrain(worldPos)) return;
// 模型之上的锚点不固定展示 popup仅通过 hover 触发(漫游强制批量模式下不过滤,需固定展示)
if (!this._isRoaming && this.isEntityOnModel(entity)) return;
@ -1414,6 +1449,10 @@ export class MapCesium implements MapInterface {
/** 每一帧都在更新,不需要持久化,但清理时一并重置 */
private popupCollisionFrames = new WeakMap<HTMLDivElement, number>();
/** 地形/模型遮挡迟滞计数:连续多帧判定被遮挡才隐藏,避免深度判定逐帧抖动导致 popup 闪烁 */
private popupOcclusionFrames = new WeakMap<HTMLDivElement, number>();
/** popup 被地形/模型遮挡后连续隐藏所需帧数 */
private readonly POPUP_OCCLUSION_HIDE_FRAMES = 3;
/** 每帧更新批量 popup 屏幕位置 + 碰撞检测(拖拽/缩放时流畅跟随) */
private updateBatchPopupPositions() {
@ -1430,15 +1469,6 @@ export class MapCesium implements MapInterface {
}
if (this.batchPopupItems.length === 0) {
// 漫游强制批量模式下容器为空:节流触发一次被动重建,
// 防止相机/模型就位后仍无 Popup配合 rebuild 的空候选容忍,不会造成清空闪烁)
if (this._isRoaming) {
const now = performance.now();
if (now - this._lastRoamPassiveRebuild > 1500) {
this._lastRoamPassiveRebuild = now;
this.rebuildBatchPopupsIfNeeded();
}
}
return;
}
@ -1446,7 +1476,8 @@ export class MapCesium implements MapInterface {
const viewW = canvas.clientWidth;
const viewH = canvas.clientHeight;
const padding = 48;
// 俯视模式下锚点图标始终置顶渲染(深度测试关闭),不会被地形遮挡,跳过遮挡检测省开销
// 俯视模式下锚点图标始终置顶渲染(深度测试关闭),不会被地形遮挡,跳过遮挡检测省开销;
// 平视时深度测试开启,遮挡检测必须生效
const skipOcclusionCheck = this._currentPitchMode === 'down';
// 第一遍:更新位置、收集可见项的屏幕矩形(用缓存宽高,不依赖 DOM 测量)
@ -1513,19 +1544,30 @@ export class MapCesium implements MapInterface {
continue;
}
// 拖拽/平视时锚点被地形遮挡 → 隐藏 popup与 rebuild 候选阶段的遮挡过滤一致);
// 漫游时跳过:模型上方锚点易被新加载的高精度地形误判为遮挡
// 遮挡判定:拖拽/平视时锚点被地形/模型遮挡 → 隐藏 popup与 rebuild 候选阶段的遮挡过滤一致)。
// 连续多帧判定被遮挡才真正隐藏(迟滞),避免深度判定逐帧抖动导致 popup 闪烁。
const x = cp.x;
const y = cp.y;
let popupBlocked = false;
if (
!skipOcclusionCheck &&
!this._isRoaming &&
this.isPositionBehindTerrain(item.worldPosition)
) {
popupBlocked = true;
}
if (popupBlocked) {
const prevOcc = this.popupOcclusionFrames.get(element) || 0;
const occCount = prevOcc > 0 ? prevOcc + 1 : 1;
this.popupOcclusionFrames.set(element, occCount);
if (occCount < this.POPUP_OCCLUSION_HIDE_FRAMES) {
continue; // 未达隐藏阈值:保留上一帧显示状态
}
element.style.display = 'none';
continue;
}
// 未被遮挡:重置遮挡迟滞计数
this.popupOcclusionFrames.delete(element);
const x = cp.x;
const y = cp.y;
// 通过所有检查后才设为可见(与开头的惰性策略配合,避免全量闪一下)
element.style.display = 'block';
element.style.left = `${x}px`;
@ -1654,6 +1696,7 @@ export class MapCesium implements MapInterface {
});
this.batchPopupItems = newItems;
this.popupCollisionFrames = new WeakMap();
this.popupOcclusionFrames = new WeakMap();
// 无缝替换容器
if (oldContainer) oldContainer.remove();
this.batchPopupContainer = newContainer;
@ -1674,6 +1717,7 @@ export class MapCesium implements MapInterface {
});
this.batchPopupItems = [];
this.popupCollisionFrames = new WeakMap<HTMLDivElement, number>();
this.popupOcclusionFrames = new WeakMap<HTMLDivElement, number>();
if (this.batchPopupContainer) {
this.batchPopupContainer.remove();
this.batchPopupContainer = null;
@ -1720,6 +1764,12 @@ export class MapCesium implements MapInterface {
this.hoverRafId = null;
}
// 漫游锚点地形预热 rAF
if (this._warmAnchorTerrainRafId !== null) {
cancelAnimationFrame(this._warmAnchorTerrainRafId);
this._warmAnchorTerrainRafId = null;
}
// 相机事件监听
if (this.removePostRenderListener) {
this.removePostRenderListener();
@ -1789,6 +1839,7 @@ export class MapCesium implements MapInterface {
// 重置所有状态变量(确保下次 init 时是干净状态)
this.popupCollisionFrames = new WeakMap();
this.popupOcclusionFrames = new WeakMap();
this.currentClipGeoJson = null;
this.originalBgColor = null;
this.skyBoxShown = true;
@ -2172,7 +2223,6 @@ export class MapCesium implements MapInterface {
hasBaseLayer(layerKey: string): boolean {
if (layerKey === 'BASEMAP-img') return !!this.imageryBaseLayer;
const hasWfs = this.wfsLayerRegistry.has(layerKey);
console.log('[WFS 3D] hasBaseLayer key:', layerKey, '→', hasWfs);
if (hasWfs) return true;
return !!this.resolvePrimaryBaseLayerKey(layerKey);
}
@ -3622,7 +3672,7 @@ export class MapCesium implements MapInterface {
}
});
// ===== 阶段3标签碰撞标签 vs 所有图标 + 标签间碰撞) =====
// ===== 阶段3标签碰撞标签 vs 所有已放置图标 + 标签间碰撞) =====
// 标签 vs 所有已放置图标:跨图层/跨优先级均检测,仅排除自己的图标
// 标签间碰撞:按优先级顺序放置,后放置的标签若与已放置的标签重叠则隐藏
const labelPlacedGrid = new Map<

View File

@ -145,6 +145,10 @@ export class MapClass implements MapClassInterface {
setRoamDepthTest(enabled: boolean): void {
this.service.setRoamDepthTest?.(enabled);
}
// 漫游模型就绪后触发批量 popup仅 3D 有效2D 直接忽略)
activateRoamingPopups(): void {
this.service.activateRoamingPopups?.();
}
// 销毁地图
destroy(): void {
this.service.destroy();

View File

@ -220,6 +220,13 @@ export interface MapInterface {
*/
setRoamDepthTest?(enabled: boolean): void;
/**
* 3D
* popup hover
* hover drillPick billboard
*/
activateRoamingPopups?(): void;
/**
*
*/

View File

@ -53,6 +53,8 @@ const FULL_DISPLAY_NO_COLLISION_ZOOM = Number.POSITIVE_INFINITY;
const POINT_FADE_DURATION = 300; // 锚点/文字碰撞显隐的淡入淡出时长(ms)
// 标签文字上方的避让边距(px):防止下方锚点文字紧贴上方锚点图标底部,看着像重叠
const LABEL_ICON_TOP_CLEARANCE = 6;
// 栖息地锚点(sttpMap=FH) 联动 fh_qxd 河段高亮:锚点 idss 字段值暂未确定,先写死为 233
const FH_ANCHOR_HIGHLIGHT_IDSS = 233;
// 定义边界 [minX, minY, maxX, maxY] (Web Mercator 坐标)
const BOUNDS_SW = [26.5, -9.99999999999929];
@ -63,6 +65,8 @@ export class MapOl implements MapInterface {
view: View | null = null;
private layerRegistry: Map<string, any> = new Map();
private iconLoadState = new Map<string, 'loading' | 'loaded' | 'error'>();
// 图标图片加载完成后缓存其原始尺寸,供 hover 命中框按实际图标大小计算
private iconSizeCache = new Map<string, { width: number; height: number }>();
private pointStyleCache = new Map<string, Style[]>();
private measureCanvas: HTMLCanvasElement | null = null;
private measureTextCache = new Map<
@ -201,6 +205,7 @@ export class MapOl implements MapInterface {
}
this.popupManager.showPopup(undefined, undefined);
this.popupManager.handleMapClick(evt.pixel, detectedFeature => {
console.log(detectedFeature);
if (detectedFeature.values_.sttpMap == 'ylfb') {
modelStore.ylfbModalVisible = true;
modelStore.params = detectedFeature.values_;
@ -216,40 +221,68 @@ export class MapOl implements MapInterface {
this.map.on('pointermove', evt => {
// fh_qxd 河段悬停高亮
let hitLayer: any = null;
const hitFeature = this.map.forEachFeatureAtPixel(
evt.pixel,
(feature, layer) => {
hitLayer = layer;
return feature;
},
feature => feature,
{
hitTolerance: 0,
layerFilter: layer => layer.get('key') === 'fh_qxd'
}
);
// 高亮图层存在时,命中要素复制到高亮图层,未命中则清空
// 命中河段或锚点时直接切换鼠标样式(与 demo 一致popup 是节流回调不能依赖它)
const targetElement = this.map?.getTargetElement() as HTMLElement;
let anchorDetect: {
detectedFeature?: Feature;
isHitIcon: boolean;
coordinate?: number[];
} = { isHitIcon: false };
if (targetElement) {
anchorDetect = this.detectFeatureAtPixel(evt.pixel);
targetElement.style.cursor =
anchorDetect.isHitIcon || hitFeature ? 'pointer' : '';
}
// 第二种高亮方式:悬停栖息地锚点(sttpMap=FH)时,按其 idss 关联值联动高亮对应 fh_qxd 河段
let anchorLinkedFeature: Feature | undefined;
const hoveredAnchor = anchorDetect.isHitIcon
? anchorDetect.detectedFeature
: undefined;
// FH 锚点识别与 popup 的 getNormalizedPopupSttpMap 保持一致:优先用 sttpMap_sttpMap/popupSttpMap
const isFhAnchor =
hoveredAnchor &&
(String(
hoveredAnchor.get('_sttpMap') ||
hoveredAnchor.get('sttpMap') ||
hoveredAnchor.get('popupSttpMap') ||
''
).toUpperCase() === 'FH' ||
String(hoveredAnchor.get('sttp') ?? '').toUpperCase() === 'FH' ||
String(hoveredAnchor.get('sttpCode') ?? '').toUpperCase() === 'FH');
if (isFhAnchor) {
// 模拟获取锚点的 idss 字段:实际数据暂未提供该字段,先注释掉真实读取,写死固定值
// 后期字段确定后只需把下面固定值替换为hoveredAnchor.get('idss')
// const anchorIdss = hoveredAnchor.get('idss');
const anchorIdss = FH_ANCHOR_HIGHLIGHT_IDSS;
// 与直接悬停检测forEachFeatureAtPixel 的 layerFilter取同一图层来源避免 registry 过期取不到图层
const fhQxdLayer = this.map
?.getLayers()
.getArray()
.find(layer => layer.get('key') === 'fh_qxd') as
| VectorLayer<VectorSource>
| undefined;
anchorLinkedFeature = fhQxdLayer
?.getSource()
?.getFeatures()
.find(f => String(f.get('Id')) === String(anchorIdss));
}
// 高亮图层存在时,命中要素复制到高亮图层,未命中则清空(锚点联动高亮优先于直接悬停河段)
if (this.wfsHighlightLayer) {
const highlightSource = this.wfsHighlightLayer.getSource();
if (hitFeature) {
// 根据命中图层的底色动态调整高亮颜色(统一加深)
const baseColor = hitLayer?.get('bjColor') || '';
// 读取命中图层原始描边宽度,保持高亮只变颜色不加粗
let highlightWidth = 5;
const layerStyle = hitLayer?.getStyle?.();
if (
layerStyle &&
typeof layerStyle === 'object' &&
!Array.isArray(layerStyle)
) {
const strokeWidth = (layerStyle as Style)
.getStroke?.()
?.getWidth?.();
if (typeof strokeWidth === 'number' && strokeWidth > 0) {
highlightWidth = strokeWidth;
}
}
const highlightFeature = anchorLinkedFeature || hitFeature;
if (highlightFeature) {
this.wfsHighlightLayer.setStyle(
new Style({
stroke: new Stroke({
@ -259,26 +292,19 @@ export class MapOl implements MapInterface {
})
);
highlightSource?.clear();
highlightSource?.addFeature(hitFeature);
highlightSource?.addFeature(highlightFeature);
} else {
highlightSource?.clear();
}
}
// 命中河段或锚点时直接切换鼠标样式(与 demo 一致popup 是节流回调不能依赖它)
const targetElement = this.map?.getTargetElement() as HTMLElement;
let isHitAnchor = false;
if (targetElement) {
isHitAnchor = this.detectFeatureAtPixel(evt.pixel).isHitIcon;
targetElement.style.cursor =
isHitAnchor || hitFeature ? 'pointer' : '';
}
this.popupManager.handlePointerMove(evt.pixel, payload => {
if (targetElement) {
// 命中河段或 hover popup 命中锚点时显示 pointer 光标
targetElement.style.cursor =
isHitAnchor || payload.hoveredId || hitFeature ? 'pointer' : '';
anchorDetect.isHitIcon || payload.hoveredId || hitFeature
? 'pointer'
: '';
}
// 该锚点已有固定展示(批量 popup的 popup跳过 hover popup避免同一内容出现双份
@ -463,6 +489,13 @@ export class MapOl implements MapInterface {
return null;
}
// 把图标原始尺寸写到要素上,供 PopupManager 按实际图标大小计算 hover 命中框
const iconSize = this.iconSizeCache.get(iconUrl);
if (iconSize) {
feature.set('_iconWidth', iconSize.width);
feature.set('_iconHeight', iconSize.height);
}
const currentZoom: any = this.view ? this.view.getZoom() : 4.5;
const cachedDensityVisible = feature.get('_densityVisible');
const densityVisible =
@ -1421,6 +1454,10 @@ export class MapOl implements MapInterface {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => {
this.iconSizeCache.set(iconUrl, {
width: image.naturalWidth || 0,
height: image.naturalHeight || 0
});
this.iconLoadState.set(iconUrl, 'loaded');
this.requestRefreshPointLayerStyles();
};
@ -1431,6 +1468,10 @@ export class MapOl implements MapInterface {
image.src = iconUrl;
if (image.complete && image.naturalWidth > 0) {
this.iconSizeCache.set(iconUrl, {
width: image.naturalWidth,
height: image.naturalHeight
});
this.iconLoadState.set(iconUrl, 'loaded');
return true;
}

View File

@ -136,6 +136,13 @@ export class PopupManager {
};
}
// 图标命中检测默认只识别不透明像素,图标带透明留白时只有中心附近能命中;
// 这里按渲染缩放放大容差,保证鼠标放到图标任意位置都能命中(命中框再由 isPixelInsideIconArea 兜底)
const zoom = this.map?.getView().getZoom() ?? 4.5;
let dynamicScale = 0.7 + (zoom - 4.5) * 0.08;
dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale));
const hitTolerance = Math.round(16 * dynamicScale);
this.map?.forEachFeatureAtPixel(
pixel,
(feature, layer) => {
@ -148,7 +155,7 @@ export class PopupManager {
if (iconPixel) {
const dx = pixel[0] - iconPixel[0];
const dy = pixel[1] - iconPixel[1];
if (this.isPixelInsideIconArea(dx, dy)) {
if (this.isPixelInsideIconArea(dx, dy, detectedFeature)) {
isHitIcon = true;
}
}
@ -158,29 +165,36 @@ export class PopupManager {
}
return false;
},
{ hitTolerance: 0 }
{ hitTolerance }
);
return { detectedFeature, isHitIcon, coordinate };
}
// 备注:仅把图标本体区域作为 hover 命中范围,避免上方文字标签被误判为图标命中。
private isPixelInsideIconArea(dx: number, dy: number): boolean {
// 备注:按图标实际渲染尺寸判断 hover 命中范围(图标锚点 [0.5, 0.5],中心即要素位置)。
// 之前用固定小方框(横向 ±12px、纵向 -8/+12px图标稍大或带透明留白时只有中心附近能命中
// 现在按 createPointStyle 写回要素的 _iconWidth/_iconHeight 计算,未取到尺寸时用保守默认值。
private isPixelInsideIconArea(
dx: number,
dy: number,
feature?: Feature
): boolean {
if (!this.map) return false;
const zoom = this.map.getView().getZoom() ?? 4.5;
let dynamicScale = 0.7 + (zoom - 4.5) * 0.08;
dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale));
const halfWidth = 12 * dynamicScale;
const topReach = 8 * dynamicScale;
const bottomReach = 12 * dynamicScale;
const iconWidth = (feature?.get('_iconWidth') as number) || 24;
const iconHeight = (feature?.get('_iconHeight') as number) || 28;
const halfWidth = (iconWidth * dynamicScale) / 2;
const halfHeight = (iconHeight * dynamicScale) / 2;
return (
dx >= -halfWidth &&
dx <= halfWidth &&
dy >= -topReach &&
dy <= bottomReach
dy >= -halfHeight &&
dy <= halfHeight
);
}

View File

@ -38,10 +38,49 @@ export interface OSGBItem {
eventListener?: any;
_loading?: boolean;
_loadId?: number;
/** 已失败次数(含首次),超过上限后置 _loadFailed不再自动重试 */
_loadRetries?: number;
/** 失败次数超限标志:为 true 时不再自动加载,需用户手动开关重置 */
_loadFailed?: boolean;
/** 用户手动关闭开关标志:为 true 时不自动加载/显示,只做显隐切换 */
_switchOff?: boolean;
}
// ==================== 加载保护(超时 / 失败上限) ====================
/** 单次 tileset.json 加载超时时间ms服务端挂起时避免 _loading 永远占用 */
const OSGB_LOAD_TIMEOUT = 30000;
/** 允许的最大失败次数(含首次),超过后停止自动重试,需用户手动开关重置 */
const OSGB_MAX_RETRIES = 3;
/** 统一失败处理:计数 + 超过上限后置失败标记(停止自动重试) */
const onLoadFail = (osgbObj: OSGBItem) => {
osgbObj._loading = false;
osgbObj._loadRetries = (osgbObj._loadRetries || 0) + 1;
if (osgbObj._loadRetries >= OSGB_MAX_RETRIES) {
osgbObj._loadFailed = true;
}
};
/** 给 promise 加超时,超时后 reject底层挂起的请求由浏览器自行回收 */
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`倾斜摄影加载超时(${ms}ms)`)),
ms
);
promise.then(
value => {
clearTimeout(timer);
resolve(value);
},
error => {
clearTimeout(timer);
reject(error);
}
);
});
// ==================== 内部状态(每个 OSGB 实例独立) ====================
interface OSGBInstanceState {
@ -100,6 +139,31 @@ const removeOSGB = (viewer: Cesium.Viewer, tileset: Cesium.Cesium3DTileset) => {
viewer.scene.primitives.remove(tileset);
};
/**
* URL tileset getOSGB
* URL undefined
*/
const findOSGBByUrl = (
viewer: Cesium.Viewer,
url: string
): Cesium.Cesium3DTileset | undefined => {
if (!viewer || viewer.isDestroyed()) return undefined;
const primitives = viewer.scene.primitives;
for (let i = 0; i < primitives.length; i++) {
const p = primitives.get(i);
if (!(p instanceof Cesium.Cesium3DTileset)) continue;
if (p.isDestroyed()) continue;
// 1.141: fromUrl 成功后 tileset._url 是字符串resource.url与传入 url 同源
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pUrl = (p as any)?._url;
if (!pUrl) continue;
const pUrlStr = String(pUrl);
// url 可能带 token 参数,双向包含判断兜底
if (pUrlStr.includes(url) || url.includes(pUrlStr)) return p;
}
return undefined;
};
// ==================== 裁切相关 ====================
/**
@ -317,6 +381,8 @@ const dynamicSetVisible = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
if (distance < 12000) {
// 用户手动关闭了开关 → 不自动加载
if (osgbObj._switchOff) return;
// 失败次数超限 → 不再自动重试(需用户手动开关重置)
if (osgbObj._loadFailed) return;
// 匹配旧代码逻辑osgbObj.osgbtitles 同步设置为 nonClassificationTileset 防重入
// 新代码用 _loading + _loadId 实现同样的效果(因为 fromUrl 是异步的)
@ -325,14 +391,15 @@ const dynamicSetVisible = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
osgbObj._loadId = (osgbObj._loadId || 0) + 1;
const loadId = osgbObj._loadId;
const url = osgbObj.url.includes('?')
? osgbObj.url + `&token=${getToken()}`
: osgbObj.url + `?token=${getToken()}`;
const url = buildUrl(osgbObj.url);
withTimeout(
Cesium.Cesium3DTileset.fromUrl(url, {
maximumScreenSpaceError: Number(osgbObj?.accuracy) || 16,
show: true,
maximumMemoryUsage: 128
})
}),
OSGB_LOAD_TIMEOUT
)
.then((tileset: Cesium.Cesium3DTileset) => {
// 竞态保护版本号变了removeQxsy 被调用或新一轮加载开始)→ 销毁退出
if (
@ -344,6 +411,7 @@ const dynamicSetVisible = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
return;
}
osgbObj._loading = false;
osgbObj._loadRetries = 0;
if (!Cesium.defined(tileset)) return;
tileset.maximumScreenSpaceError = 32;
@ -373,9 +441,8 @@ const dynamicSetVisible = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
addOSGB(viewer, tileset);
osgbObj.osgbtitles = tileset;
})
.catch((e: Error) => {
osgbObj._loading = false;
console.log('dynamicSetVisible 加载倾斜摄影报错', e);
.catch(() => {
onLoadFail(osgbObj);
});
}
} else if (distance > 13000) {
@ -436,6 +503,23 @@ const dynamicClipTerrain = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
// ==================== 公开 API ====================
/** GIS 18085 CORS fetch Network
* /gis-map vite/nginx
* tileset.json base URL */
const GIS_PROXY_PREFIX = '/gis-map';
const GIS_ORIGIN_REGEX = /https?:\/\/211\.99\.26\.225:18085/i;
const toSameOriginUrl = (url: string): string =>
url.replace(GIS_ORIGIN_REGEX, GIS_PROXY_PREFIX);
const buildUrl = (url: string): string => {
// 跨域绝对地址 → 同源反代地址
const u = toSameOriginUrl(url);
// 如果已包含 token不再添加
if (u.includes('token=')) return u;
const token = getToken();
if (!token) return u;
return u.includes('?') ? `${u}&token=${token}` : `${u}?token=${token}`;
};
/**
* 3D Tileset
*
@ -448,6 +532,9 @@ export const LoadOSGB = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
// 已加载则跳过
if (osgbObj.osgbtitles && !osgbObj.osgbtitles.isDestroyed?.()) return;
// 失败次数超限 → 不再自动重试(需用户手动开关重置)
if (osgbObj._loadFailed) return;
// 正在加载中也跳过(防止重复调用 fromUrl
if (osgbObj._loading) return;
osgbObj._loading = true;
@ -456,15 +543,26 @@ export const LoadOSGB = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
osgbObj._loadId = (osgbObj._loadId || 0) + 1;
const loadId = osgbObj._loadId;
const url = osgbObj.url.includes('?')
? osgbObj.url + `&token=${getToken()}`
: osgbObj.url + `?token=${getToken()}`;
const url = buildUrl(osgbObj.url);
// 去重复用:场景中已有同 URL 的 tileset → 直接复用,不再发请求(旧项目 getOSGB 逻辑)
const existing = findOSGBByUrl(viewer, url);
if (existing) {
osgbObj.osgbtitles = existing;
osgbObj.cartesian3 = existing.boundingSphere?.center?.clone();
osgbObj._loading = false;
osgbObj._loadRetries = 0;
return;
}
withTimeout(
Cesium.Cesium3DTileset.fromUrl(url, {
maximumScreenSpaceError: Number(osgbObj?.accuracy) || 16,
show: true,
maximumMemoryUsage: 128
})
}),
OSGB_LOAD_TIMEOUT
)
.then((tileset: Cesium.Cesium3DTileset) => {
// 竞态保护:版本号变了 → 销毁退出
if (
@ -478,6 +576,7 @@ export const LoadOSGB = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
if (!Cesium.defined(tileset)) return;
osgbObj._loading = false;
osgbObj._loadRetries = 0;
tileset.maximumScreenSpaceError = 32;
// height 向上偏移模型几何自带高程height 只是微调(如 44m
@ -532,9 +631,8 @@ export const LoadOSGB = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
dynamicClipTerrain(viewer, osgbObj);
});
})
.catch((e: Error) => {
osgbObj._loading = false;
console.log('加载倾斜摄影报错', e);
.catch(() => {
onLoadFail(osgbObj);
});
};
@ -550,6 +648,9 @@ export const removeQxsy = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
osgbObj.clippingPlanes = undefined;
osgbObj._loading = false;
osgbObj._switchOff = undefined;
// 重置失败标记,重新进入后允许重新加载
osgbObj._loadFailed = false;
osgbObj._loadRetries = 0;
if (typeof osgbObj.eventListener === 'function') {
osgbObj.eventListener();
@ -589,6 +690,9 @@ export const osgbChangeClick = (
}
if (checked) {
// 用户主动开启:重置失败标记,允许重新加载
osgbObj._loadFailed = false;
osgbObj._loadRetries = 0;
// 开关 ON已有未销毁的 tileset → 直接显示;否则加载
if (osgbObj.osgbtitles && !osgbObj.osgbtitles.isDestroyed?.()) {
osgbObj.osgbtitles.show = true;

View File

@ -211,6 +211,8 @@ async function initRoamManager(): Promise<void> {
currentProgress.value = seconds;
}
);
// CZML popup
MapClass.getInstance().activateRoamingPopups?.();
}
// ==================== API Calls ====================

View File

@ -178,15 +178,38 @@ export const useMapOrchestrator = () => {
}
);
// 同时等待两个 promise
const [{ layerConfig }, { legendOriginal, pageLegend }] =
await Promise.all([layerConfigPromise, legendConfigPromise]);
// 仅等待图层配置:配置就绪即可发起锚点数据加载。
// 图例接口在后台就绪后再补设,避免图例慢时拖住所有锚点数据的加载。
const { layerConfig } = await layerConfigPromise;
if (pageLoadRequestId !== activePageLoadRequestId) {
return;
}
// 先设置图层数据(更新 checkedLayerKeys再设置图例数据
// 后台等待图例接口:就绪后补设图例数据,并重刷已勾选 pointMap 图层,
// 补齐“图例未就绪时锚点已先渲染”的情况下的按图例过滤显隐。
void legendConfigPromise
.then(({ legendOriginal, pageLegend }) => {
if (pageLoadRequestId !== activePageLoadRequestId) return;
if (legendOriginal.length > 0) {
mapStore.setLegendData(legendOriginal, pageLegend);
mapViewStore.getCheckedLayerKeys().forEach(key => {
if (!key || key === '-') return;
const layerItem = mapStore.findLayerByKey(
mapStore.layerData,
key
);
if (layerItem?.type === 'pointMap' && layerItem.url) {
mapStore.refreshPointLayerDisplayData(key);
}
});
}
})
.catch(() => {
// 图例接口失败不影响地图锚点加载
});
// 先设置图层数据(更新 checkedLayerKeys图例已由后台 legendConfigPromise 补设
if (layerConfig.length > 0) {
hydroMenuDefaultCheckedKeys = new Set(
mapConfigStore.extractCheckedLayerKeys(layerConfig)
@ -221,11 +244,6 @@ export const useMapOrchestrator = () => {
}
}
// 设置图例数据(此时 checkedLayerKeys 已正确设置)
if (legendOriginal.length > 0) {
mapStore.setLegendData(legendOriginal, pageLegend);
}
const activePageToken = mapStore.activatePageContext(
pageKey,
layerConfig
@ -425,10 +443,15 @@ export const useMapOrchestrator = () => {
await initializeMapShell(options);
bindBaseSelection();
bindZoomListener(options.getIsHydroMenu);
// 备注3D 视角飞入与页面图层/锚点加载并行触发,不再等全部锚点加载完成才飞入中国视角,
// 锚点数据在飞行途中增量上屏,显著缩短 3D 首屏等待。2D 下 flyToDefaultView 为空实现,并行无副作用。
const flyToDefaultTask = mapClass.flyToDefaultView?.();
await loadPage({ pageKey: options.pageKey, isInitialLoad: true });
// 加载完边界线等动态图层后,再从地球视图飞入中国视角
if (mapClass.flyToDefaultView) {
await mapClass.flyToDefaultView();
if (flyToDefaultTask) {
await flyToDefaultTask;
}
await syncZoomSensitiveState({
isHydroMenu: options.getIsHydroMenu(),

View File

@ -14,6 +14,7 @@ import { useMapConfigStore } from '@/modules/map/stores/map-config.store';
import { useMapDataStore } from '@/modules/map/stores/map-data.store';
import { useMapViewStore } from '@/modules/map/stores/map-view.store';
import request from '@/utils/request';
import { getIconPath } from '@/utils/index';
const mapClass = MapClass.getInstance();
const ENG_POINT_LAYER_KEY = 'eng_point';
const ENG_ALARM_POINT_LAYER_KEY = 'eng_alarm_point';
@ -209,6 +210,16 @@ export const useMapStore = defineStore('map', () => {
cacheKey?: string;
}
>();
// 备注:图例配置未就绪时到达的锚点原始数据暂存区,待图例就绪后统一归一化并上屏,
// 避免锚点响应先于图例配置到达时被归一化为空导致数据丢失。
const pendingLegendRawLayerData = new Map<
string,
{
checked: boolean;
data: any[];
cacheKey?: string;
}
>();
let loadSessionSeed = 0;
let activePageRenderToken = 0;
let activePageKey = '';
@ -576,6 +587,70 @@ export const useMapStore = defineStore('map', () => {
}
};
// 备注:图例配置就绪后,把暂存的锚点原始数据统一归一化并上屏,
// 修复"锚点响应先于图例配置到达"时数据被过滤为空的竞态。
const flushPendingLegendLayerData = () => {
if (
pendingLegendRawLayerData.size === 0 ||
legendDataOriginal.value.length === 0
) {
return;
}
pendingLegendRawLayerData.forEach((payload, key) => {
if (!shouldApplyToActivePage(key)) {
return;
}
const layer = findLayerByKey(layerData.value, key);
if (!layer) {
return;
}
const list = normalizePointLayerItems(payload.data);
pendingLegendRawLayerData.delete(key);
if (list.length === 0) {
return;
}
mapDataStore.setPointLayerCache(key, {
checked: payload.checked,
data: list,
cacheKey: payload.cacheKey
});
if (shouldSyncMergedPointData(key)) {
syncPointDataForFilter(getRuntimeCheckedLayerKeys());
}
layer.data = list;
applyLoadedPointLayerToMap(layer, key, list);
mapDataStore.setLayerLoaded(key);
});
};
// 备注图例配置就绪后预取锚点图标图片new Image 预热),
// 锚点数据到达时图标已就绪,首屏一次出图,减少等待图标异步加载的空白期。
const prefetchLegendPointIcons = () => {
const prefetched = new Set<string>();
const walk = (items: any[] = []) => {
items.forEach(item => {
if (item?.childrenList?.length > 0) {
walk(item.childrenList);
return;
}
const iconUrl = getIconPath(item?.icon || '');
if (!iconUrl || prefetched.has(iconUrl)) {
return;
}
prefetched.add(iconUrl);
const image = new Image();
image.crossOrigin = 'anonymous';
image.src = iconUrl;
});
};
walk(legendDataOriginal.value);
};
/**
* name == '地图' ifShow == 0
* @param data -
@ -588,6 +663,9 @@ export const useMapStore = defineStore('map', () => {
buildLegendCheckedState(legendDataOriginal.value, normalizeLegendNameEn)
);
rebuildLegendRuntimeData(checkedLayerKeys.value);
// 图例就绪后:冲刷暂存锚点数据并预热锚点图标,让提前到达的数据及时上屏。
flushPendingLegendLayerData();
prefetchLegendPointIcons();
};
/**
@ -859,18 +937,30 @@ export const useMapStore = defineStore('map', () => {
processItems(items);
const allTasks = [...checkedTasks, ...uncheckedTasks];
// 方案三:逐个处理 —— 内部不 await 批量完成,每个请求独立释放 HTTP 连接
// 勾选图层优先:先并发发起勾选图层数据请求(每层返回即渲染锚点),
// 全部勾选图层完成后,再后台并发加载未勾选图层,避免同时抢占连接拖慢默认勾选图层。
void (async () => {
try {
const loadResults = await Promise.allSettled(
allTasks.map(task => task())
const checkedResults = await Promise.allSettled(
checkedTasks.map(task => task())
);
const failedResults = loadResults.filter(
const failedChecked = checkedResults.filter(
result => result.status === 'rejected'
);
if (failedChecked.length > 0) {
console.warn(
'勾选图层数据加载失败,但不会阻断其他图层:',
failedChecked
);
}
const uncheckedResults = await Promise.allSettled(
uncheckedTasks.map(task => task())
);
const failedResults = [
...failedChecked,
...uncheckedResults.filter(result => result.status === 'rejected')
];
if (failedResults.length > 0) {
console.warn(
'部分图层数据加载失败,但不会阻断其他图层:',
@ -1330,18 +1420,30 @@ export const useMapStore = defineStore('map', () => {
}
}
list = normalizePointLayerItems(
list.map((item: any) => ({
const rawList = list.map((item: any) => ({
...item,
layerKey: item.layerKey || key
}))
);
}));
if (activeLayerRequestKeyMap.get(key) !== requestIdentifier) {
return [];
}
const isLayerChecked = layer.checked === 1;
// 备注:图例配置未就绪时锚点无法解析 iconCode立即归一化会被过滤为空导致数据丢失。
// 此时暂存原始数据,待图例配置就绪后由 flushPendingLegendLayerData 统一归一化并上屏。
if (legendDataOriginal.value.length === 0) {
pendingLegendRawLayerData.set(key, {
checked: isLayerChecked,
data: rawList,
cacheKey: requestIdentifier
});
return rawList;
}
list = normalizePointLayerItems(rawList);
const cachePayload = {
checked: isLayerChecked,
data: list,
@ -1461,6 +1563,8 @@ export const useMapStore = defineStore('map', () => {
activeLayerRequestKeyMap.delete(entry.layerKey);
}
backgroundPointLayerCache.clear();
// 页面切换时清空图例暂存数据,避免旧页面的锚点原始数据被冲刷到新页面。
pendingLegendRawLayerData.clear();
};
/**

View File

@ -38,6 +38,13 @@ export default ({ mode }: ConfigEnv): UserConfig => {
target: 'http://localhost:5174',
changeOrigin: true
},
// GIS 服务反代18085 服务端不返回 CORS 头,浏览器直接跨域请求会被拦截
// 前端将 https://211.99.26.225:18085 改写为 /gis-map由本代理转发对齐旧项目相对路径方案
'/gis-map': {
target: 'https://211.99.26.225:18085',
changeOrigin: true,
secure: false
},
'/api/dec-lygk-base-server': {
target: 'https://211.99.26.225:12122',
changeOrigin: true,