添加栖息地锚点hover效果,优化3d加载,漫游加载
This commit is contained in:
parent
66f90555ff
commit
a948f0bb8d
@ -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`;
|
||||
|
||||
@ -298,7 +298,7 @@ watch(
|
||||
console.log('isActive', modelStore.filter.year);
|
||||
if (modelStore.filter.year) {
|
||||
searchParams.value.tm = modelStore.filter.year;
|
||||
getFtpOptions();
|
||||
getFtpOptions();
|
||||
} else {
|
||||
getYearList();
|
||||
}
|
||||
|
||||
@ -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`;
|
||||
|
||||
@ -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<
|
||||
|
||||
@ -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();
|
||||
|
||||
7
frontend/src/components/gis/map.d.ts
vendored
7
frontend/src/components/gis/map.d.ts
vendored
@ -220,6 +220,13 @@ export interface MapInterface {
|
||||
*/
|
||||
setRoamDepthTest?(enabled: boolean): void;
|
||||
|
||||
/**
|
||||
* 漫游模型就绪后调用(仅 3D 有效)
|
||||
* 漫游中不启用固定批量 popup,仅 hover 展示;本方法预热所有锚点位置的地形瓦片,
|
||||
* 保证 hover 的 drillPick 能命中贴地 billboard
|
||||
*/
|
||||
activateRoamingPopups?(): void;
|
||||
|
||||
/**
|
||||
* 长度量算
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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()}`;
|
||||
Cesium.Cesium3DTileset.fromUrl(url, {
|
||||
maximumScreenSpaceError: Number(osgbObj?.accuracy) || 16,
|
||||
show: true,
|
||||
maximumMemoryUsage: 128
|
||||
})
|
||||
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);
|
||||
|
||||
Cesium.Cesium3DTileset.fromUrl(url, {
|
||||
maximumScreenSpaceError: Number(osgbObj?.accuracy) || 16,
|
||||
show: true,
|
||||
maximumMemoryUsage: 128
|
||||
})
|
||||
// 去重复用:场景中已有同 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;
|
||||
|
||||
@ -211,6 +211,8 @@ async function initRoamManager(): Promise<void> {
|
||||
currentProgress.value = seconds;
|
||||
}
|
||||
);
|
||||
// 漫游模型(CZML)加载并锁定第一人称相机后,触发批量 popup(漫游中不受缩放阈值限制)
|
||||
MapClass.getInstance().activateRoamingPopups?.();
|
||||
}
|
||||
|
||||
// ==================== API Calls ====================
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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) => ({
|
||||
...item,
|
||||
layerKey: item.layerKey || key
|
||||
}))
|
||||
);
|
||||
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();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user