优化2d和3d的地图展示 优化漫游,锚点添加淡入淡出效果
This commit is contained in:
parent
ae2ff1ced4
commit
2622b9d511
@ -7,10 +7,6 @@ const appStore = useAppStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
1.地图抽吸
|
||||
2.地图文字和点显示
|
||||
-->
|
||||
<el-config-provider :locale="appStore.locale" :size="appStore.size">
|
||||
<a-config-provider :theme="usetTheme" :locale="locale">
|
||||
<router-view />
|
||||
|
||||
@ -160,24 +160,6 @@ onUnmounted(() => {
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
}
|
||||
/* 消除瓦片之间的缝隙和默认边框 */
|
||||
.leaflet-tile {
|
||||
border: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
/* 确保地图容器背景色与瓦片一致,避免透出底色 */
|
||||
.leaflet-container {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* 如果使用 Canvas 模式,确保 Canvas 没有边框 */
|
||||
.leaflet-pane canvas {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.map-popup-container {
|
||||
z-index: 1000;
|
||||
|
||||
@ -638,7 +638,9 @@ const renderChart = async () => {
|
||||
startValue: nextStartValue,
|
||||
endValue: nextEndValue
|
||||
});
|
||||
} catch (_error: any) {}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}, 32);
|
||||
};
|
||||
zr.on('mousewheel', handleWheel);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,4 @@
|
||||
import type { layer, MapInterface } from './map.d';
|
||||
// import { MapLeaflet } from "./map.leaflet";
|
||||
import { MapOl } from './map.ol';
|
||||
import { MapCesium } from './map.cesium';
|
||||
|
||||
@ -29,7 +28,6 @@ export class MapClass implements MapClassInterface {
|
||||
constructor() {
|
||||
this.layers = new Map();
|
||||
this.view = null;
|
||||
// this.service = new MapLeaflet();
|
||||
this.service = new MapOl();
|
||||
}
|
||||
static getInstance(): MapClass {
|
||||
@ -143,6 +141,10 @@ export class MapClass implements MapClassInterface {
|
||||
removePointLayer(layerKey: string): void {
|
||||
this.service.removePointLayer?.(layerKey);
|
||||
}
|
||||
// 漫游模式锚点深度测试开关(仅 3D 有效,2D 无此能力直接忽略)
|
||||
setRoamDepthTest(enabled: boolean): void {
|
||||
this.service.setRoamDepthTest?.(enabled);
|
||||
}
|
||||
// 销毁地图
|
||||
destroy(): void {
|
||||
this.service.destroy();
|
||||
|
||||
7
frontend/src/components/gis/map.d.ts
vendored
7
frontend/src/components/gis/map.d.ts
vendored
@ -213,6 +213,13 @@ export interface MapInterface {
|
||||
checked: boolean
|
||||
): void;
|
||||
|
||||
/**
|
||||
* 漫游模式锚点深度测试开关(仅 3D 有效)
|
||||
* 漫游时启用后,锚点文字/图标开启深度测试,被漫游模型正确遮挡
|
||||
* @param enabled 是否处于漫游状态
|
||||
*/
|
||||
setRoamDepthTest?(enabled: boolean): void;
|
||||
|
||||
/**
|
||||
* 长度量算
|
||||
*/
|
||||
|
||||
@ -1,440 +0,0 @@
|
||||
// import { MapInterface } from './map';
|
||||
// import * as L from 'leaflet';
|
||||
// import * as esriLeaflet from 'esri-leaflet';
|
||||
// import '@/utils/leaflet/leaflet-tilelayer-wmts-src.js';
|
||||
// import { mapServerBaseUrl } from './map.class';
|
||||
// import '@/utils/leaflet/leaflet.inflatable-markers-group.js';
|
||||
// import {MDOptions} from './map.class';
|
||||
// import { getIconPath } from "@/utils/index";
|
||||
// // @ts-ignore
|
||||
// import axios from 'axios';
|
||||
// // import "@/components/thematicMap/leaflet/leaflet.inflatable-markers-group.js"
|
||||
|
||||
// const tiledMapGroup = L.layerGroup();
|
||||
// const chartMapGroup = L.layerGroup();
|
||||
// const overlayGroup = L.layerGroup();
|
||||
// const CENTER_positionCN: any = [38, 114.17112499999996]; //中心纬经度 中国
|
||||
// const basinCenter = {
|
||||
// DA_HHGLSX: [103.343357, 35.931812],
|
||||
// FA_CJGLSX: [111.001911, 30.821327]
|
||||
// };
|
||||
// let boundCavansLayer: any = null;
|
||||
// let ganliulist: any = [];
|
||||
// export class MapLeaflet implements MapInterface {
|
||||
// map: any = null;
|
||||
// htmlMakerLayer: any = [];
|
||||
// defaultScale = 10;
|
||||
// minimumZoom = 7;
|
||||
// setDrawPlug: any = null;
|
||||
// layermarkers: any = [];
|
||||
// rainlayerslist: any = [];
|
||||
// private layerRegistry: Map<string, any> = new Map(); // ✅ 新增:存储 key -> layer 实例
|
||||
|
||||
// private currentBaseLayerKey: string | null = null; // ✅ 新增:记录当前激活的底图 Key
|
||||
|
||||
// temperatureMapObj: any = [];
|
||||
// //地图初始化
|
||||
// init(container: HTMLElement, rectangle?: any): Promise<any> {
|
||||
// try {
|
||||
// console.log('init初始化container', container);
|
||||
// var corner1 = L.latLng(55.35715491537772, 140.7821677051657);
|
||||
// var corner2 = L.latLng(0.975580441812298, 67.56008229483018);
|
||||
// var bounds = L.latLngBounds(corner2, corner1);
|
||||
// const map = L.map(container as any, {
|
||||
// preferCanvas: true,
|
||||
// zoom: 4.5,
|
||||
// minZoom: 4.23,
|
||||
// maxZoom: 22, // 【修改点1】增大最大缩放级别,允许用户继续滚轮放大
|
||||
// maxNativeZoom: 12,
|
||||
// zoomSnap: 0.1, // 【修改点2】让缩放更平滑,不强制对齐整数
|
||||
// zoomDelta: 0.5,
|
||||
// center: CENTER_positionCN,
|
||||
// attributionControl: false,
|
||||
// zoomControl: false,
|
||||
// trackResize: true,
|
||||
// maxBounds: bounds,
|
||||
// maxBoundsViscosity: 1.0,
|
||||
// wheelPxPerZoomLevel: 180,
|
||||
// });
|
||||
// // ✅ 新增:可视化显示 maxBounds 范围
|
||||
// // L.rectangle(bounds, {color: "#ff7800", weight: 3, opacity: 0.8, fillOpacity: 0.1})
|
||||
// // .addTo(map)
|
||||
// // .bindPopup("这是 maxBounds 的范围");
|
||||
// this.map = map;
|
||||
// this.map.on('zoomend', (e: any) => {
|
||||
// console.log('当前缩放级别', e.target.getZoom());
|
||||
// });
|
||||
// return Promise.resolve(map);
|
||||
// } catch (e) {
|
||||
// console.log('测试', e);
|
||||
// return Promise.reject({});
|
||||
// }
|
||||
// }
|
||||
// addBaseDataLayer(layer: any): any {
|
||||
// // The WMTS URL
|
||||
// console.log(layer);
|
||||
// switch (layer.type) {
|
||||
// case 'wmts':
|
||||
// if (layer.url) {
|
||||
// console.log('https://211.99.26.225:18085' + layer.url);
|
||||
// if (layer) {
|
||||
// var ignLayer = L.tileLayer
|
||||
// .wmts('https://211.99.26.225:18085/geoserver/gwc/service/wmts', {
|
||||
// tileMatrixSet: 'EPSG:3857_qgc_qsj_arcgistiles_l13',
|
||||
// tileSize: 256, //切片大小
|
||||
// maxZoom: 13,
|
||||
// noWrap: true,
|
||||
// opacity: 0.99,
|
||||
// minZoom: 4,
|
||||
// styles:{
|
||||
// abc:123
|
||||
// },
|
||||
// layer: 'qgc_qsj_arcgistiles_l13'
|
||||
// })
|
||||
// .addTo(this.map)
|
||||
// .bringToBack();
|
||||
// const registryKey = layer.key || layer.title;
|
||||
// if (registryKey) {
|
||||
// this.layerRegistry.set(registryKey, ignLayer);
|
||||
// }
|
||||
// layer._layer = ignLayer;
|
||||
// layer._layer.layerGroup = overlayGroup;
|
||||
// }
|
||||
// }
|
||||
// return layer;
|
||||
|
||||
// case 'markers':
|
||||
// this.getRain(layer);
|
||||
// break;
|
||||
// default: {
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// baseLayerSwitcher(key: string) {
|
||||
// if (!this.map) return;
|
||||
|
||||
// console.log('切换底图 key:', key);
|
||||
|
||||
// // 1. 如果当前已有底图且不是同一个,先移除
|
||||
// if (this.currentBaseLayerKey && this.currentBaseLayerKey !== key) {
|
||||
// const oldLayer = this.layerRegistry.get(this.currentBaseLayerKey);
|
||||
// if (oldLayer && this.map.hasLayer(oldLayer)) {
|
||||
// this.map.removeLayer(oldLayer);
|
||||
// console.log(`已移除旧底图: ${this.currentBaseLayerKey}`);
|
||||
// }
|
||||
// }
|
||||
|
||||
// // 2. 检查新底图是否已存在 registry 中
|
||||
// let newLayer = this.layerRegistry.get(key);
|
||||
|
||||
// // 3. 如果不存在,则创建并添加到地图和 registry
|
||||
// if (!newLayer) {
|
||||
// newLayer = this.createBaseLayerByKey(key);
|
||||
// if (newLayer) {
|
||||
// this.layerRegistry.set(key, newLayer);
|
||||
// newLayer.addTo(this.map);
|
||||
// // 确保底图在最底层
|
||||
// newLayer.bringToBack();
|
||||
// }
|
||||
// } else {
|
||||
// // 4. 如果已存在,直接添加(如果未添加)
|
||||
// if (!this.map.hasLayer(newLayer)) {
|
||||
// newLayer.addTo(this.map);
|
||||
// newLayer.bringToBack();
|
||||
// }
|
||||
// }
|
||||
|
||||
// // 5. 更新当前激活的 Key
|
||||
// if (newLayer) {
|
||||
// this.currentBaseLayerKey = key;
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * 根据 Key 创建具体的 Leaflet 图层实例
|
||||
// */
|
||||
// createBaseLayerByKey(key: string): L.Layer | null {
|
||||
// console.log(key)
|
||||
// const tdtToken = 'e90d56e5a09d1767899ad45846b0cefd' //企业版密钥,勿换e650f138c4481cca888cd13094bb9026
|
||||
// const mapType = 'w' //c:天地图经纬度底图;w:天地图墨卡托底图
|
||||
// const URL_TerTDT = `https://t0.tianditu.gov.cn/ter_${mapType}/wmts?tk=${tdtToken}` //terMap
|
||||
// switch (key) {
|
||||
// case 's_province_boundaries':
|
||||
// // 假设这是之前的 WMTS 矢量图层
|
||||
// // 注意:原代码中硬编码了 URL 和 layer 名称,这里需要确保与实际服务对应
|
||||
// return L.tileLayer.wmts('https://211.99.26.225:18085/geoserver/gwc/service/wmts', {
|
||||
// tileMatrixSet: 'EPSG:3857_qgc_qsj_arcgistiles_l13',
|
||||
// tileSize: 256,
|
||||
// // maxZoom: 13,
|
||||
// noWrap: true,
|
||||
// opacity: 1,
|
||||
// minZoom: 4,
|
||||
// zIndex: 1,
|
||||
// layer: 'qgc_qsj_arcgistiles_l13', // 请确认此 layer 名称是否对应“矢量”
|
||||
// format: 'image/png'
|
||||
// });
|
||||
|
||||
// case 'BASEMAP-white':
|
||||
// // 地形图示例 (使用 OpenStreetMap 或其他地形服务作为占位,请替换为实际地址)
|
||||
// return L.tileLayer(`${URL_TerTDT}&SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ter&STYLE=default&TILEMATRIXSET=${mapType}&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=tiles`, {
|
||||
// // maxZoom: 17,
|
||||
// format: "image/png",
|
||||
// zIndex: 12,
|
||||
// attribution: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap (CC-BY-SA)'
|
||||
// });
|
||||
|
||||
// case 'BASEMAP-img':
|
||||
// // 影像图示例 (使用 Esri World Imagery 作为占位,请替换为实际地址)
|
||||
// return L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||
// attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
|
||||
// });
|
||||
|
||||
// default:
|
||||
// console.warn(`未知的底图 Key: ${key}`);
|
||||
// return null;
|
||||
// }
|
||||
// }
|
||||
|
||||
// controlBaseLayerTreeShowAndHidden(layerType: String, key: String, checked: boolean) {
|
||||
// // 优先使用 key 查找,如果没有 key 则尝试用 layerType
|
||||
// // console.log(this.getAllLayers())
|
||||
// console.log(key)
|
||||
// console.log(this.layerRegistry)
|
||||
// const registryKey:any = key || layerType;
|
||||
// const layerInstance = this.layerRegistry.get(registryKey);
|
||||
// if (layerInstance) {
|
||||
// if (checked) {
|
||||
// // 显示:如果不在地图上,则添加
|
||||
// if (!this.map.hasLayer(layerInstance)) {
|
||||
// layerInstance.addTo(this.map);
|
||||
// }
|
||||
// } else {
|
||||
// // 隐藏:如果在地图上,则移除
|
||||
// if (this.map.hasLayer(layerInstance)) {
|
||||
// this.map.removeLayer(layerInstance);
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// console.warn(`未找到标识为 ${registryKey} 的图层实例`);
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * 添加初始化数据图层
|
||||
// * @param pointLayerObj 图层配置对象 (包含 data, key, checked 等属性)
|
||||
// * @param layerType 图层类型/Key (如果 pointLayerObj 中有 key,则优先使用 obj.key)
|
||||
// * @param mdoptions 描点选项配置
|
||||
// * @param legendArray 图例映射数据 (用于根据 anchoPointState 匹配图标)
|
||||
// */
|
||||
// addInitDataLayer = (pointLayerObj: any, layerType?: any, mdoptions?: MDOptions, legendArray?: any) => {
|
||||
// // 1. 参数校验与数据提取
|
||||
// if (!this.map || !pointLayerObj) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// let dataArray: any[] = [];
|
||||
// let targetLayerKey: string = layerType;
|
||||
|
||||
// if (Array.isArray(pointLayerObj)) {
|
||||
// dataArray = pointLayerObj;
|
||||
// } else {
|
||||
// dataArray = pointLayerObj.data || [];
|
||||
// targetLayerKey = pointLayerObj.key || layerType;
|
||||
// }
|
||||
|
||||
// if (!targetLayerKey) {
|
||||
// console.warn('缺少图层 Key,无法加载描点');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (dataArray.length === 0) {
|
||||
// const existingGroup = this.layerRegistry.get(targetLayerKey);
|
||||
// if (existingGroup && existingGroup instanceof L.LayerGroup) {
|
||||
// existingGroup.clearLayers();
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
|
||||
// console.log(`开始加载图层 [${targetLayerKey}] 的描点,数量: ${dataArray.length}`);
|
||||
|
||||
// // 2. 获取或创建该图层的 LayerGroup
|
||||
// let layerGroup = this.layerRegistry.get(targetLayerKey);
|
||||
|
||||
// const shouldClear = mdoptions?.isRemove !== false;
|
||||
|
||||
// if (!layerGroup || !(layerGroup instanceof L.LayerGroup)) {
|
||||
// if (layerGroup && this.map.hasLayer(layerGroup)) {
|
||||
// this.map.removeLayer(layerGroup);
|
||||
// }
|
||||
// layerGroup = L.layerGroup();
|
||||
// this.layerRegistry.set(targetLayerKey, layerGroup);
|
||||
// layerGroup.addTo(this.map);
|
||||
// } else {
|
||||
// if (shouldClear) {
|
||||
// layerGroup.clearLayers();
|
||||
// }
|
||||
// }
|
||||
|
||||
// // 3. 遍历数据生成 Marker
|
||||
// dataArray.forEach((item: any) => {
|
||||
// const { lgtd, lttd, stcd, stnm, iconCode, anchoPointState, _id, titleName, ennm } = item;
|
||||
|
||||
// if (lgtd == null || lttd == null) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // 4. 确定图标样式和文字
|
||||
// let iconUrl = '';
|
||||
// let iconSize = [15, 15];
|
||||
|
||||
// // 获取图标 URL
|
||||
// if (iconCode) {
|
||||
// iconUrl = getIconPath(iconCode);
|
||||
// } else if (anchoPointState && legendArray) {
|
||||
// const legendItem = legendArray[anchoPointState];
|
||||
// if (legendItem && legendItem.icon) {
|
||||
// iconUrl = getIconPath(legendItem.icon);
|
||||
// if (legendItem.width && legendItem.height) {
|
||||
// iconSize = [legendItem.width, legendItem.height];
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // 如果没有找到图标,使用默认或跳过
|
||||
// if (!iconUrl) {
|
||||
// // 可以选择使用默认图标或者继续
|
||||
// iconUrl = getIconPath('default'); // 假设有一个默认图标
|
||||
// if(!iconUrl) return;
|
||||
// }
|
||||
|
||||
// // 5. 创建带文字的 DivIcon
|
||||
// // 显示的文字优先使用 titleName,其次 ennm,最后 stnm
|
||||
// const labelText = titleName || ennm || stnm || '';
|
||||
|
||||
// // 构建 HTML 结构:一个容器包含图片和文字
|
||||
// // 注意:这里使用内联样式简单演示,建议在实际项目中提取到 CSS 类中
|
||||
// const iconHtml = `
|
||||
// <div style="position: relative; display: flex; flex-direction: column; align-items: center; width: max-content;">
|
||||
// <div style="
|
||||
// font: 10px sans-serif;
|
||||
// font-weight: bold;
|
||||
// color: #fff;
|
||||
// white-space: nowrap;
|
||||
// margin-bottom: 2px;
|
||||
// -webkit-text-stroke: 2px #2e2d2d;
|
||||
// paint-order: stroke fill; /* 确保描边在填充外部,防止文字变细 */
|
||||
|
||||
// ">
|
||||
// ${labelText}
|
||||
// </div>
|
||||
// <img src="${iconUrl}" style="width: ${iconSize[0]}px; height: ${iconSize[1]}px; display: block;" />
|
||||
// </div>`;
|
||||
|
||||
// const customIcon = L.divIcon({
|
||||
// html: iconHtml,
|
||||
// className: '', // 重要:设置为空字符串以避免 Leaflet 默认样式干扰
|
||||
// iconSize: [iconSize[0], iconSize[1] + 20], // 高度增加以容纳文字 (假设文字高约20px)
|
||||
// iconAnchor: [iconSize[0] / 2, iconSize[1] + 10], // 锚点设在图片底部中心
|
||||
// popupAnchor: [0, -(iconSize[1] + 20)] // 弹窗锚点设在整体顶部
|
||||
// });
|
||||
|
||||
// // 6. 创建 Marker
|
||||
// const marker = L.marker([lttd, lgtd], {
|
||||
// icon: customIcon,
|
||||
// // @ts-ignore
|
||||
// options: {
|
||||
// ...item,
|
||||
// layerKey: targetLayerKey,
|
||||
// originalEvent: item
|
||||
// }
|
||||
// });
|
||||
|
||||
// // 7. 绑定点击事件 (如果需要)
|
||||
// marker.on('click', (e: any) => {
|
||||
// // 触发全局事件,例如:
|
||||
// // GlobalEvents.get('map_dataLayer_click').set(item);
|
||||
// console.log('Marker clicked:', item);
|
||||
// });
|
||||
|
||||
// // 8. 将 Marker 添加到 LayerGroup
|
||||
// layerGroup.addLayer(marker);
|
||||
// });
|
||||
|
||||
// console.log(`图层 [${targetLayerKey}] 描点加载完成`);
|
||||
// }
|
||||
// getRain(data: any) {
|
||||
// if (data?.geojson) {
|
||||
// this.removeRainLayer();
|
||||
// this.removeLayermarkers();
|
||||
// const renderColor = (item: any, colRules: any) => {
|
||||
// let color = 'rgba(0,0,0,0)';
|
||||
// colRules.map((rule: any, index: number) => {
|
||||
// if (
|
||||
// item.properties.hvalue >= rule?.sv &&
|
||||
// item.properties.hvalue < rule?.ev
|
||||
// ) {
|
||||
// color = rule.colors;
|
||||
// }
|
||||
// if (index === colRules.length - 1) {
|
||||
// if (item.properties.hvalue >= rule?.ev) {
|
||||
// color = rule.colors;
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// return color;
|
||||
// };
|
||||
|
||||
// data.geojson.features.map((item: any, index: number) => {
|
||||
// let pushlist = L.geoJSON(item, {
|
||||
// style: {
|
||||
// color: renderColor(item, data.colRules),
|
||||
// weight: 2,
|
||||
// opacity: 0.8,
|
||||
// fillOpacity: 0.7
|
||||
// // fillColor: "#1D91C0",
|
||||
// }
|
||||
// }).addTo(this.map);
|
||||
// this.layermarkers.push(pushlist);
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// removeLayermarkers = () => {
|
||||
// // console.log('layermarkers',layermarkers);
|
||||
|
||||
// try {
|
||||
// if (this.layermarkers.length > 0) {
|
||||
// if (this.map.hasLayer(this.layermarkers)) {
|
||||
// this.map.removeLayer(this.layermarkers);
|
||||
// } else {
|
||||
// console.log('移除图层失败,应已经移除');
|
||||
// }
|
||||
// }
|
||||
// // removeAllGeojson(map);
|
||||
// } catch (e) {
|
||||
// // message.info('移除图层失败')
|
||||
// console.log('移除图层失败,是否应移除');
|
||||
// }
|
||||
// };
|
||||
|
||||
// removeRainLayer(): void {
|
||||
// console.log('test!删除Layer');
|
||||
// const _this = this;
|
||||
// if (this.rainlayerslist.length > 0) {
|
||||
// this.rainlayerslist.map((item: any) => {
|
||||
// _this.map.removeLayer(item);
|
||||
// });
|
||||
// this.rainlayerslist = [];
|
||||
// }
|
||||
// } // 缩放
|
||||
// zoomToggle(type: 'out' | 'in') {
|
||||
// if (this.map) {
|
||||
// if (type === 'out') {
|
||||
// this.map && this.map.zoomOut();
|
||||
// } else {
|
||||
// this.map && this.map.zoomIn();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@ -50,6 +50,9 @@ const MAX_ZOOM = 22;
|
||||
const INITIAL_ZOOM = 4.5;
|
||||
const BATCH_POPUP_MODE_ZOOM = 15;
|
||||
const FULL_DISPLAY_NO_COLLISION_ZOOM = Number.POSITIVE_INFINITY;
|
||||
const POINT_FADE_DURATION = 300; // 锚点/文字碰撞显隐的淡入淡出时长(ms)
|
||||
// 标签文字上方的避让边距(px):防止下方锚点文字紧贴上方锚点图标底部,看着像重叠
|
||||
const LABEL_ICON_TOP_CLEARANCE = 6;
|
||||
|
||||
// 定义边界 [minX, minY, maxX, maxY] (Web Mercator 坐标)
|
||||
const BOUNDS_SW = [26.5, -9.99999999999929];
|
||||
@ -61,6 +64,13 @@ export class MapOl implements MapInterface {
|
||||
private layerRegistry: Map<string, any> = new Map();
|
||||
private iconLoadState = new Map<string, 'loading' | 'loaded' | 'error'>();
|
||||
private pointStyleCache = new Map<string, Style[]>();
|
||||
private measureCanvas: HTMLCanvasElement | null = null;
|
||||
private measureTextCache = new Map<
|
||||
string,
|
||||
{ width: number; height: number }
|
||||
>();
|
||||
private fadeRefreshFrameId: number | null = null;
|
||||
private fadingFeatures = new Set<Feature>();
|
||||
private baseLayerConfig: any | null = null;
|
||||
private hydropBaseConfig: any | null = null;
|
||||
private REGISTRY_KEY = 'customBaseLayer';
|
||||
@ -75,7 +85,6 @@ export class MapOl implements MapInterface {
|
||||
private BASEID = '';
|
||||
private currentClipGeoJson: any | null = null;
|
||||
private clipRequestController: AbortController | null = null;
|
||||
private hoveredFeatureId: string | number | null = null;
|
||||
private wfsHighlightLayer: VectorLayer | null = null;
|
||||
private popupManager: PopupManager;
|
||||
private regionMaskManager: RegionMaskManager;
|
||||
@ -256,25 +265,30 @@ export class MapOl implements MapInterface {
|
||||
}
|
||||
}
|
||||
|
||||
// 命中河段时直接切换鼠标样式(与 demo 一致,popup 是节流回调不能依赖它)
|
||||
// 命中河段或锚点时直接切换鼠标样式(与 demo 一致,popup 是节流回调不能依赖它)
|
||||
const targetElement = this.map?.getTargetElement() as HTMLElement;
|
||||
let isHitAnchor = false;
|
||||
if (targetElement) {
|
||||
targetElement.style.cursor = hitFeature ? 'pointer' : '';
|
||||
isHitAnchor = this.detectFeatureAtPixel(evt.pixel).isHitIcon;
|
||||
targetElement.style.cursor =
|
||||
isHitAnchor || hitFeature ? 'pointer' : '';
|
||||
}
|
||||
|
||||
this.popupManager.handlePointerMove(evt.pixel, payload => {
|
||||
this.hoveredFeatureId = payload.hoveredId;
|
||||
|
||||
if (targetElement) {
|
||||
// 命中河段或 hover popup 命中锚点时显示 pointer 光标
|
||||
targetElement.style.cursor =
|
||||
payload.hoveredId || hitFeature ? 'pointer' : '';
|
||||
isHitAnchor || payload.hoveredId || hitFeature ? 'pointer' : '';
|
||||
}
|
||||
|
||||
// 批量 popup 模式下不触发 hover popup
|
||||
if (!this.isBatchPopupMode) {
|
||||
this.showPopup(payload.detectedFeature, payload.coordinate);
|
||||
}
|
||||
// 该锚点已有固定展示(批量 popup)的 popup,跳过 hover popup,避免同一内容出现双份;
|
||||
// 未被固定展示的锚点(如碰撞被隐藏)仍可通过 hover 查看,且 hover popup 展示时会临时提升层级,不被固定展示遮挡
|
||||
const fixedShown =
|
||||
payload.detectedFeature?.get('_fixedPopupShown') === true;
|
||||
this.showPopup(
|
||||
fixedShown ? undefined : payload.detectedFeature,
|
||||
fixedShown ? undefined : payload.coordinate
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -475,25 +489,48 @@ export class MapOl implements MapInterface {
|
||||
const iconCollisionVisible = feature.get('_iconCollisionVisible') !== false;
|
||||
const labelCollisionVisible =
|
||||
feature.get('_labelCollisionVisible') === true;
|
||||
const finalIconVisible = iconTargetVisible && iconCollisionVisible;
|
||||
const labelTargetVisible =
|
||||
!this.isBatchPopupMode &&
|
||||
finalIconVisible &&
|
||||
!!formattedLabelText &&
|
||||
labelCollisionVisible;
|
||||
|
||||
if (!finalIconVisible) {
|
||||
// 淡入淡出 alpha(null = 未在淡变中)
|
||||
const iconAlpha = this.getFadeAlpha(feature, 'icon');
|
||||
const labelAlpha = this.getFadeAlpha(feature, 'label');
|
||||
const isIconFading = iconAlpha !== null;
|
||||
const isLabelFading = labelAlpha !== null;
|
||||
|
||||
const finalIconVisible = iconTargetVisible && iconCollisionVisible;
|
||||
// 碰撞隐藏时仍在淡出中则继续渲染,动画结束(alpha 归 0)后才会真正隐藏
|
||||
const iconRendered = finalIconVisible || isIconFading;
|
||||
|
||||
// 图例/区域/密度隐藏:不做淡出,直接隐藏
|
||||
if (!iconTargetVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 碰撞隐藏且不在淡出中:直接隐藏
|
||||
if (!iconRendered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelRenderVisible =
|
||||
!this.isBatchPopupMode &&
|
||||
iconRendered &&
|
||||
!!formattedLabelText &&
|
||||
(labelCollisionVisible || isLabelFading);
|
||||
|
||||
const iconRenderAlpha = iconAlpha ?? 1;
|
||||
const labelRenderAlpha = labelAlpha ?? 1;
|
||||
// 文字淡变跟随整锚点:取锚点与文字各自 alpha 的最小值
|
||||
const effectiveLabelAlpha = Math.min(iconRenderAlpha, labelRenderAlpha);
|
||||
const fading = isIconFading || isLabelFading;
|
||||
|
||||
const styleCacheKey = [
|
||||
iconUrl,
|
||||
dynamicScale.toFixed(2),
|
||||
fontSize,
|
||||
engPriority,
|
||||
labelOffsetY,
|
||||
labelTargetVisible ? formattedLabelText : '',
|
||||
labelTargetVisible ? 1 : 0
|
||||
labelRenderVisible ? formattedLabelText : '',
|
||||
labelRenderVisible ? 1 : 0,
|
||||
fading ? 1 : 0
|
||||
].join('|');
|
||||
const cachedStyles = this.pointStyleCache.get(styleCacheKey);
|
||||
if (cachedStyles) {
|
||||
@ -508,12 +545,13 @@ export class MapOl implements MapInterface {
|
||||
scale: dynamicScale,
|
||||
anchor: [0.5, 0.5],
|
||||
crossOrigin: 'anonymous',
|
||||
declutterMode: 'none'
|
||||
declutterMode: 'none',
|
||||
opacity: iconRenderAlpha
|
||||
})
|
||||
})
|
||||
];
|
||||
|
||||
if (formattedLabelText && labelTargetVisible) {
|
||||
if (formattedLabelText && labelRenderVisible) {
|
||||
styles.push(
|
||||
new Style({
|
||||
zIndex: engPriority + 1,
|
||||
@ -521,9 +559,11 @@ export class MapOl implements MapInterface {
|
||||
text: formattedLabelText,
|
||||
offsetY: labelOffsetY,
|
||||
font: `${fontSize}px sans-serif`,
|
||||
fill: new Fill({ color: '#fff' }),
|
||||
fill: new Fill({
|
||||
color: `rgba(255, 255, 255, ${effectiveLabelAlpha})`
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: 'rgba(0, 0, 0, 0.9)',
|
||||
color: `rgba(0, 0, 0, ${0.9 * effectiveLabelAlpha})`,
|
||||
width: 2
|
||||
}),
|
||||
textAlign: 'center',
|
||||
@ -533,7 +573,9 @@ export class MapOl implements MapInterface {
|
||||
);
|
||||
}
|
||||
|
||||
this.pointStyleCache.set(styleCacheKey, styles);
|
||||
if (!fading) {
|
||||
this.pointStyleCache.set(styleCacheKey, styles);
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
@ -543,7 +585,8 @@ export class MapOl implements MapInterface {
|
||||
visible: boolean,
|
||||
dirtyLayerKeys?: Set<string>
|
||||
) {
|
||||
if (feature.get(key) === visible) {
|
||||
const prev = feature.get(key);
|
||||
if (prev === visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -552,6 +595,21 @@ export class MapOl implements MapInterface {
|
||||
if (dirtyLayerKeys && layerKey) {
|
||||
dirtyLayerKeys.add(String(layerKey));
|
||||
}
|
||||
|
||||
// 碰撞显隐切换时启动淡入淡出(初始状态 prev 为 undefined 时不做动画)
|
||||
if (key === '_iconCollisionVisible') {
|
||||
if (prev === true && !visible) {
|
||||
this.startFade(feature, 'out', 'icon');
|
||||
} else if (prev === false && visible) {
|
||||
this.startFade(feature, 'in', 'icon');
|
||||
}
|
||||
} else if (key === '_labelCollisionVisible') {
|
||||
if (prev === true && !visible) {
|
||||
this.startFade(feature, 'out', 'label');
|
||||
} else if (prev === false && visible) {
|
||||
this.startFade(feature, 'in', 'label');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private flushDirtyPointLayers(dirtyLayerKeys: Set<string>) {
|
||||
@ -561,6 +619,127 @@ export class MapOl implements MapInterface {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动锚点(icon)或文字(label)的淡入/淡出动画。
|
||||
* 对应需求:碰撞隐藏时不直接 show=false,而是打上淡出标记 + 起始时间,
|
||||
* 由 createPointStyle(等价于 Cesium 的 alpha 回调)每帧计算 alpha 驱动淡出。
|
||||
*/
|
||||
private startFade(
|
||||
feature: Feature,
|
||||
dir: 'in' | 'out',
|
||||
target: 'icon' | 'label'
|
||||
) {
|
||||
const outFlag = target === 'icon' ? '_isFadingOut' : '_labelFadingOut';
|
||||
const outStart =
|
||||
target === 'icon' ? '_fadeStartTime' : '_labelFadeStartTime';
|
||||
const inFlag = target === 'icon' ? '_isFadingIn' : '_labelFadingIn';
|
||||
const inStart =
|
||||
target === 'icon' ? '_fadeInStartTime' : '_labelFadeInStartTime';
|
||||
|
||||
if (dir === 'out') {
|
||||
if (feature.get(outFlag)) return;
|
||||
feature.set(outFlag, true, true);
|
||||
feature.set(outStart, performance.now(), true);
|
||||
// 若正在淡入则中断,改向淡出
|
||||
feature.set(inFlag, false, true);
|
||||
feature.set(inStart, 0, true);
|
||||
} else {
|
||||
if (feature.get(inFlag)) return;
|
||||
feature.set(inFlag, true, true);
|
||||
feature.set(inStart, performance.now(), true);
|
||||
// 若正在淡出则中断,改向淡入
|
||||
feature.set(outFlag, false, true);
|
||||
feature.set(outStart, 0, true);
|
||||
}
|
||||
|
||||
this.fadingFeatures.add(feature);
|
||||
this.requestFadeRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算锚点/文字的当前 alpha。
|
||||
* 淡出 alpha 1.0→0.0,淡入 0.0→1.0;未在淡变中返回 null。
|
||||
* 动画结束分支:清除淡变标记(自动清理),并返回 null,
|
||||
* 由调用方按碰撞标志决定最终显隐(等价于"结束时可安全置 show=false")。
|
||||
*/
|
||||
private getFadeAlpha(
|
||||
feature: Feature,
|
||||
target: 'icon' | 'label'
|
||||
): number | null {
|
||||
const outFlag = target === 'icon' ? '_isFadingOut' : '_labelFadingOut';
|
||||
const outStart =
|
||||
target === 'icon' ? '_fadeStartTime' : '_labelFadeStartTime';
|
||||
const inFlag = target === 'icon' ? '_isFadingIn' : '_labelFadingIn';
|
||||
const inStart =
|
||||
target === 'icon' ? '_fadeInStartTime' : '_labelFadeInStartTime';
|
||||
|
||||
if (feature.get(outFlag)) {
|
||||
const alpha = this.computeFadeAlpha(feature.get(outStart), false);
|
||||
if (alpha === null) {
|
||||
// 淡出结束:自动清理标记
|
||||
feature.set(outFlag, false, true);
|
||||
feature.set(outStart, 0, true);
|
||||
}
|
||||
return alpha;
|
||||
}
|
||||
if (feature.get(inFlag)) {
|
||||
const alpha = this.computeFadeAlpha(feature.get(inStart), true);
|
||||
if (alpha === null) {
|
||||
// 淡入结束:自动清理标记
|
||||
feature.set(inFlag, false, true);
|
||||
feature.set(inStart, 0, true);
|
||||
}
|
||||
return alpha;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 根据起始时间与当前时间计算 alpha;动画已结束返回 null */
|
||||
private computeFadeAlpha(
|
||||
startTime: unknown,
|
||||
fadingIn: boolean
|
||||
): number | null {
|
||||
const start = Number(startTime) || 0;
|
||||
const elapsed = performance.now() - start;
|
||||
if (elapsed >= POINT_FADE_DURATION) {
|
||||
return null;
|
||||
}
|
||||
const progress = Math.max(0, elapsed / POINT_FADE_DURATION);
|
||||
return fadingIn ? progress : 1 - progress;
|
||||
}
|
||||
|
||||
/** 请求一次淡变重绘;已有循环在跑则忽略 */
|
||||
private requestFadeRefresh() {
|
||||
if (this.fadeRefreshFrameId !== null) return;
|
||||
this.fadeRefreshFrameId = requestAnimationFrame(() =>
|
||||
this.tickFadeRefresh()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 淡变驱动循环:每帧让所有点图层重绘一次(createPointStyle 会读取最新 alpha),
|
||||
* 直到没有任何 feature 处于淡变中(此时已由 getFadeAlpha 清理标记,样式函数返回最终显隐)。
|
||||
*/
|
||||
private tickFadeRefresh() {
|
||||
this.fadeRefreshFrameId = null;
|
||||
const stillFading: Feature[] = [];
|
||||
this.fadingFeatures.forEach(feature => {
|
||||
if (
|
||||
this.getFadeAlpha(feature, 'icon') !== null ||
|
||||
this.getFadeAlpha(feature, 'label') !== null
|
||||
) {
|
||||
stillFading.push(feature);
|
||||
}
|
||||
});
|
||||
this.fadingFeatures = new Set(stillFading);
|
||||
this.pointLayerManager.forEachLayer(layer => layer.changed());
|
||||
if (this.fadingFeatures.size > 0) {
|
||||
this.fadeRefreshFrameId = requestAnimationFrame(() =>
|
||||
this.tickFadeRefresh()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private syncFeatureDensityVisible(
|
||||
feature: Feature,
|
||||
visible: boolean,
|
||||
@ -937,15 +1116,18 @@ export class MapOl implements MapInterface {
|
||||
const iconBottom = entry.pixelY + iconCollisionSize / 2 + iconPadding;
|
||||
const labelText = entry.labelText;
|
||||
const fontSize = this.getPointFontSize(dynamicScale);
|
||||
const lines = labelText ? labelText.split('\n') : [''];
|
||||
const maxLineLength = Math.max(...lines.map(line => line.length), 1);
|
||||
const labelLineCount = lines.length;
|
||||
const labelOffsetY = this.getPointLabelCollisionOffsetY(
|
||||
const labelLineCount = labelText ? labelText.split('\n').length : 1;
|
||||
// 碰撞偏移与渲染偏移统一,保证碰撞块与实际渲染文字完全重合
|
||||
const labelOffsetY = this.getPointLabelRenderOffsetY(
|
||||
dynamicScale,
|
||||
labelLineCount
|
||||
);
|
||||
const estimatedWidth = maxLineLength * fontSize * 0.6 + 16;
|
||||
const estimatedHeight = labelLineCount * (fontSize + 4) + 8;
|
||||
// 碰撞块大小 = 文字实测像素大小(Canvas 2D measureText 精确测量,与 3D 对齐)
|
||||
const measured = labelText
|
||||
? this.measureLabelPixelSize(labelText, fontSize)
|
||||
: { width: 0, height: 0 };
|
||||
const labelWidth = measured.width;
|
||||
const labelHeight = measured.height;
|
||||
const centerX = entry.pixelX;
|
||||
const centerY = entry.pixelY + labelOffsetY;
|
||||
const candidateId = String(
|
||||
@ -967,10 +1149,10 @@ export class MapOl implements MapInterface {
|
||||
iconTop,
|
||||
iconBottom,
|
||||
hasLabel: !!labelText,
|
||||
left: centerX - estimatedWidth / 2,
|
||||
right: centerX + estimatedWidth / 2,
|
||||
top: centerY - estimatedHeight / 2,
|
||||
bottom: centerY + estimatedHeight / 2,
|
||||
left: centerX - labelWidth / 2,
|
||||
right: centerX + labelWidth / 2,
|
||||
top: centerY - labelHeight / 2,
|
||||
bottom: centerY + labelHeight / 2,
|
||||
priority: Number(entry.feature.get('_nearbyPriority') || 9999),
|
||||
engPriority: this.getFeatureEngRenderPriority(entry.feature),
|
||||
densityPriority: entry.densityPriority,
|
||||
@ -1307,6 +1489,71 @@ export class MapOl implements MapInterface {
|
||||
});
|
||||
}
|
||||
|
||||
/** 复用离屏 canvas 获取 2d context */
|
||||
private getMeasureCtx(): CanvasRenderingContext2D | null {
|
||||
if (!this.measureCanvas) {
|
||||
this.measureCanvas = document.createElement('canvas');
|
||||
this.measureCanvas.width = 1;
|
||||
this.measureCanvas.height = 1;
|
||||
}
|
||||
return this.measureCanvas.getContext('2d');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 Canvas 2D measureText 精确测量文字在屏幕上的像素宽度和高度(与 3D 实现对齐),
|
||||
* 碰撞块的大小 = 文字实际大小。结果按 `text@roundedFontSize` 缓存,fontSize 随 zoom 变化时自动失效。
|
||||
* 宽度优先取墨迹实测(actualBoundingBoxLeft/Right),避免 CJK 字体右留白导致块比文字宽。
|
||||
* @param text 标签文字(支持多行 \n 分隔)
|
||||
* @param fontSize 标签实际使用的像素字号
|
||||
* @returns { width, height } 像素尺寸
|
||||
*/
|
||||
private measureLabelPixelSize(
|
||||
text: string,
|
||||
fontSize: number
|
||||
): { width: number; height: number } {
|
||||
const roundedFs = Math.round(fontSize);
|
||||
const cacheKey = `${text}@${roundedFs}`;
|
||||
const cached = this.measureTextCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const ctx = this.getMeasureCtx();
|
||||
// fallback:canvas 不可用时用简单估算(极少发生)
|
||||
if (!ctx) {
|
||||
const lines = text.split('\n');
|
||||
const maxLen = Math.max(...lines.map(l => l.length), 1);
|
||||
const fallback = {
|
||||
width: maxLen * roundedFs + 20,
|
||||
height: lines.length * (roundedFs + 4) + 8
|
||||
};
|
||||
this.measureTextCache.set(cacheKey, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
ctx.font = `${roundedFs}px sans-serif`;
|
||||
const lines = text.split('\n');
|
||||
|
||||
let maxWidth = 0;
|
||||
for (const line of lines) {
|
||||
const metrics = ctx.measureText(line);
|
||||
// 墨迹宽 = 左右墨迹边界之和;浏览器不支持 actualBoundingBox 时退回排版宽
|
||||
const inkWidth =
|
||||
metrics.actualBoundingBoxLeft !== undefined &&
|
||||
metrics.actualBoundingBoxRight !== undefined
|
||||
? metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight
|
||||
: metrics.width;
|
||||
maxWidth = Math.max(maxWidth, inkWidth);
|
||||
}
|
||||
|
||||
// 宽度 = 文字墨迹像素宽度(取多行中最宽的一行,无额外边距)
|
||||
const width = maxWidth;
|
||||
// 高度 = 行数 × 字号(与 3D 对齐,作为文字块的像素高度)
|
||||
const height = lines.length * roundedFs;
|
||||
|
||||
const result = { width, height };
|
||||
this.measureTextCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private checkLabelCollision(
|
||||
left: { left: number; right: number; top: number; bottom: number },
|
||||
right: { left: number; right: number; top: number; bottom: number }
|
||||
@ -1334,12 +1581,20 @@ export class MapOl implements MapInterface {
|
||||
top: iconRect.top + inset,
|
||||
bottom: iconRect.bottom - inset
|
||||
};
|
||||
// 标签块向上扩展避让边距(等价于给文字加"上边距"),
|
||||
// 让下方锚点文字不再紧贴上方锚点图标底部(含文字描边/图标视觉边缘)看着像重叠
|
||||
const extendedLabelRect = {
|
||||
left: labelRect.left,
|
||||
right: labelRect.right,
|
||||
top: labelRect.top - LABEL_ICON_TOP_CLEARANCE,
|
||||
bottom: labelRect.bottom
|
||||
};
|
||||
|
||||
return !(
|
||||
narrowedIconRect.right + padding <= labelRect.left ||
|
||||
narrowedIconRect.left - padding >= labelRect.right ||
|
||||
narrowedIconRect.bottom + padding <= labelRect.top ||
|
||||
narrowedIconRect.top - padding >= labelRect.bottom
|
||||
narrowedIconRect.right + padding <= extendedLabelRect.left ||
|
||||
narrowedIconRect.left - padding >= extendedLabelRect.right ||
|
||||
narrowedIconRect.bottom + padding <= extendedLabelRect.top ||
|
||||
narrowedIconRect.top - padding >= extendedLabelRect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
@ -1397,17 +1652,6 @@ export class MapOl implements MapInterface {
|
||||
return labelLineCount > 1 ? -30 * dynamicScale : -22 * dynamicScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一计算标签碰撞时的纵向偏移。
|
||||
* 这里允许比视觉位置略高一点,避免相邻点图标过早压掉主标签。
|
||||
*/
|
||||
private getPointLabelCollisionOffsetY(
|
||||
dynamicScale: number,
|
||||
labelLineCount: number
|
||||
): number {
|
||||
return labelLineCount > 1 ? -36 * dynamicScale : -28 * dynamicScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* distance 字段表示点位密度分档,不是物理距离。
|
||||
* 这里仅用它决定点位从哪个缩放级别开始参与显示候选。
|
||||
@ -2684,6 +2928,13 @@ export class MapOl implements MapInterface {
|
||||
this.clearBatchPopupPostRenderSync();
|
||||
this.removeQueryLayer();
|
||||
this.regionMaskManager.destroy();
|
||||
this.measureTextCache.clear();
|
||||
this.measureCanvas = null;
|
||||
if (this.fadeRefreshFrameId !== null) {
|
||||
cancelAnimationFrame(this.fadeRefreshFrameId);
|
||||
this.fadeRefreshFrameId = null;
|
||||
}
|
||||
this.fadingFeatures.clear();
|
||||
|
||||
const clearLayer = (layer: any) => {
|
||||
if (layer && layer.getSource) {
|
||||
|
||||
@ -38,8 +38,6 @@ export class PopupManager {
|
||||
private lastHoveredId: string | number | null = null;
|
||||
private animationFrameId: number | null = null;
|
||||
private getPointLayers: () => VectorLayer<VectorSource>[];
|
||||
private popupMouseEnterHandler: (() => void) | null = null;
|
||||
private popupMouseMoveHandler: (() => void) | null = null;
|
||||
private hoverChangeHandler:
|
||||
| ((payload: PointerMoveChangePayload) => void)
|
||||
| undefined;
|
||||
@ -63,17 +61,6 @@ export class PopupManager {
|
||||
this.popupElement.style.removeProperty('left');
|
||||
this.popupElement.style.removeProperty('top');
|
||||
this.popupElement.style.setProperty('pointer-events', 'none', 'important');
|
||||
this.popupMouseEnterHandler = () => {
|
||||
this.forceHidePopup();
|
||||
};
|
||||
this.popupMouseMoveHandler = () => {
|
||||
this.forceHidePopup();
|
||||
};
|
||||
this.popupElement.addEventListener(
|
||||
'mouseenter',
|
||||
this.popupMouseEnterHandler
|
||||
);
|
||||
this.popupElement.addEventListener('mousemove', this.popupMouseMoveHandler);
|
||||
this.popupOverlay = new Overlay({
|
||||
element: this.popupElement,
|
||||
positioning: 'bottom-center',
|
||||
@ -209,12 +196,43 @@ export class PopupManager {
|
||||
this.popupElement.innerHTML = popupHtml;
|
||||
this.popupOverlay.setPosition(coordinate);
|
||||
this.popupElement.style.display = 'block';
|
||||
this.raisePopupLayer();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.popupOverlay.setPosition(undefined);
|
||||
this.popupElement.style.display = 'none';
|
||||
this.restorePopupLayer();
|
||||
}
|
||||
|
||||
// 备注:hover popup 展示时临时提升层级,避免被批量固定 popup 容器(z-index: 1000)遮挡。
|
||||
// OL Overlay 会把元素包在 .ol-overlay-container 包装层里,该层又挂在 z-index: 0 的
|
||||
// .ol-overlaycontainer 下,单独抬升 popup 元素或其包装层都无法越过顶层容器的层叠上下文,
|
||||
// 因此必须同时抬升 map.getOverlayContainer()(.ol-overlaycontainer)才能盖过批量容器。
|
||||
private raisePopupLayer() {
|
||||
if (!this.popupElement) return;
|
||||
this.popupElement.style.zIndex = '1100';
|
||||
if (this.popupElement.parentElement) {
|
||||
this.popupElement.parentElement.style.zIndex = '1100';
|
||||
}
|
||||
const container = this.map?.getOverlayContainer();
|
||||
if (container) {
|
||||
container.style.zIndex = '1001';
|
||||
}
|
||||
}
|
||||
|
||||
// 备注:隐藏 hover popup 时恢复层级,避免长期抬高影响其它 Overlay。
|
||||
private restorePopupLayer() {
|
||||
if (!this.popupElement) return;
|
||||
this.popupElement.style.removeProperty('zIndex');
|
||||
if (this.popupElement.parentElement) {
|
||||
this.popupElement.parentElement.style.removeProperty('zIndex');
|
||||
}
|
||||
const container = this.map?.getOverlayContainer();
|
||||
if (container) {
|
||||
container.style.zIndex = '0';
|
||||
}
|
||||
}
|
||||
|
||||
private getPopupHtml(props: Record<string, any>) {
|
||||
@ -340,6 +358,8 @@ export class PopupManager {
|
||||
popupEl.style.visibility = 'visible';
|
||||
popupEl.style.left = `${popupX}px`;
|
||||
popupEl.style.top = `${popupY}px`;
|
||||
// 标记该锚点已有固定展示的 popup,hover 时跳过 hover popup
|
||||
feature.set('_fixedPopupShown', true);
|
||||
this.batchPopupItems.push({
|
||||
feature,
|
||||
element: popupEl
|
||||
@ -353,6 +373,7 @@ export class PopupManager {
|
||||
});
|
||||
} else {
|
||||
// 有碰撞,移除该 popup
|
||||
feature.set('_fixedPopupShown', false);
|
||||
batchContainer.removeChild(popupEl);
|
||||
}
|
||||
});
|
||||
@ -373,26 +394,32 @@ export class PopupManager {
|
||||
const viewportPadding = 48;
|
||||
|
||||
this.batchPopupItems.forEach(({ feature, element }) => {
|
||||
// 统一维护固定展示标记:显示时置 true、隐藏时置 false
|
||||
const setVisible = (visible: boolean) => {
|
||||
element.style.display = visible ? 'block' : 'none';
|
||||
feature.set('_fixedPopupShown', visible);
|
||||
};
|
||||
|
||||
if (feature.get('_legendVisible') === false) {
|
||||
element.style.display = 'none';
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (styleChecker && !styleChecker(feature)) {
|
||||
element.style.display = 'none';
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const geom = feature.getGeometry();
|
||||
if (!geom || geom.getType() !== 'Point') {
|
||||
element.style.display = 'none';
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const coords = (geom as Point).getCoordinates();
|
||||
const pixel = this.map?.getPixelFromCoordinate(coords);
|
||||
if (!pixel) {
|
||||
element.style.display = 'none';
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -404,18 +431,22 @@ export class PopupManager {
|
||||
popupX > viewportWidth + viewportPadding ||
|
||||
popupY > viewportHeight + viewportPadding
|
||||
) {
|
||||
element.style.display = 'none';
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
element.style.left = `${popupX}px`;
|
||||
element.style.top = `${popupY}px`;
|
||||
element.style.display = 'block';
|
||||
setVisible(true);
|
||||
});
|
||||
}
|
||||
|
||||
// 备注:清除批量显示的 popups。
|
||||
clearBatchPopups() {
|
||||
// 清除前复位所有要素的固定展示标记
|
||||
this.batchPopupItems.forEach(({ feature }) => {
|
||||
feature.set('_fixedPopupShown', false);
|
||||
});
|
||||
this.batchPopupItems = [];
|
||||
if (this.batchPopupContainer) {
|
||||
this.batchPopupContainer.remove();
|
||||
@ -456,15 +487,6 @@ export class PopupManager {
|
||||
});
|
||||
}
|
||||
|
||||
// 备注:在弹窗层意外接管鼠标时,通过统一 hover 回调链路立即清空当前悬停状态。
|
||||
private forceHidePopup() {
|
||||
this.emitHoverChange({
|
||||
hoveredId: null,
|
||||
detectedFeature: undefined,
|
||||
coordinate: undefined
|
||||
});
|
||||
}
|
||||
|
||||
// 备注:统一派发 hover 变化,确保地图侧的 cursor、Popup 和图层刷新走同一条处理链路。
|
||||
private emitHoverChange(payload: PointerMoveChangePayload) {
|
||||
this.lastHoveredId = payload.hoveredId;
|
||||
@ -505,23 +527,9 @@ export class PopupManager {
|
||||
destroy() {
|
||||
this.clearBatchPopups();
|
||||
this.reset();
|
||||
if (this.popupElement && this.popupMouseEnterHandler) {
|
||||
this.popupElement.removeEventListener(
|
||||
'mouseenter',
|
||||
this.popupMouseEnterHandler
|
||||
);
|
||||
}
|
||||
if (this.popupElement && this.popupMouseMoveHandler) {
|
||||
this.popupElement.removeEventListener(
|
||||
'mousemove',
|
||||
this.popupMouseMoveHandler
|
||||
);
|
||||
}
|
||||
if (this.map && this.popupOverlay) {
|
||||
this.map.removeOverlay(this.popupOverlay);
|
||||
}
|
||||
this.popupMouseEnterHandler = null;
|
||||
this.popupMouseMoveHandler = null;
|
||||
this.hoverChangeHandler = undefined;
|
||||
this.popupOverlay = null;
|
||||
this.popupElement = null;
|
||||
|
||||
@ -23,6 +23,9 @@ export class ThreeDRoamManager {
|
||||
// 相机平滑跟随插值状态
|
||||
private _smoothCenter: Cesium.Cartesian3 | null = null;
|
||||
private _smoothOrientation: Cesium.Quaternion | null = null;
|
||||
// 点击跳转后的过渡截止时间(performance.now() 时间戳):
|
||||
// 过渡期内 setCameraLookAt 使用更大的插值系数,让相机平滑“飞”向目标而非瞬移
|
||||
private _transitionEnd = 0;
|
||||
|
||||
constructor(viewer: any) {
|
||||
this.viewer = viewer;
|
||||
@ -116,6 +119,8 @@ export class ThreeDRoamManager {
|
||||
this.viewer.clock.shouldAnimate = false;
|
||||
this.entitiesVisible(true);
|
||||
this.removeTickCallback();
|
||||
// 不清除平滑插值状态:暂停后相机仍会平滑收敛到模型位置再停下(保留缓冲,
|
||||
// 避免“立刻暂停”的突兀感);此时时钟已冻结,目标固定,不会产生抖动
|
||||
}
|
||||
|
||||
/** 重置飞行(回到起点/第0帧) */
|
||||
@ -177,21 +182,17 @@ export class ThreeDRoamManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 视角定位到指定时间点的位置 */
|
||||
/**
|
||||
* 视角定位到指定时间点的位置
|
||||
* 说明:不再使用 camera.flyTo —— 漫游中第一人称跟随(setCameraLookAt)每帧执行
|
||||
* lookAtTransform,camera.flyTo 会与其争抢相机控制,导致相机高度/zoom 振荡,
|
||||
* 锚点文字随之“先缩小后重新放大”。改为开启一段过渡窗口,
|
||||
* setCameraLookAt 在窗口内用更大的插值系数平滑“飞”向目标时间点的跟随视角。
|
||||
* 位置/朝向仍由 setCameraLookAt 每帧从模型实时取值,因此无需在此读取坐标。
|
||||
*/
|
||||
flyToPositionAtTime(seconds: number): void {
|
||||
if (!this.roamingEntity || !this.startTime) return;
|
||||
const time = Cesium.JulianDate.addSeconds(
|
||||
this.startTime,
|
||||
seconds,
|
||||
new Cesium.JulianDate()
|
||||
);
|
||||
const position = this.roamingEntity.position?.getValue(time);
|
||||
if (position) {
|
||||
this.viewer.camera.flyTo({
|
||||
destination: position,
|
||||
duration: 0.5
|
||||
});
|
||||
}
|
||||
this._transitionEnd = performance.now() + 500;
|
||||
}
|
||||
|
||||
/** 是否已加载漫游实体 */
|
||||
@ -267,8 +268,14 @@ export class ThreeDRoamManager {
|
||||
const targetOrientation = entity.orientation?.getValue(time);
|
||||
if (!targetCenter || !targetOrientation) return;
|
||||
|
||||
// 插值系数:越小越平滑(跟随延迟越大)
|
||||
const lerpFactor = 0.06;
|
||||
// 插值系数:常规跟随 0.1(越小越平滑,跟随延迟越大);
|
||||
// 点击跳转后的过渡期内 0.15 → 0.1 线性衰减,让相机快速且平滑地“飞”向目标位置
|
||||
const now = performance.now();
|
||||
let lerpFactor = 0.1;
|
||||
if (now < this._transitionEnd) {
|
||||
const t = 1 - (this._transitionEnd - now) / 500; // 0 → 1
|
||||
lerpFactor = 0.15 + (0.1 - 0.15) * t;
|
||||
}
|
||||
|
||||
// 对位置做 lerp
|
||||
let center: Cesium.Cartesian3;
|
||||
@ -360,4 +367,4 @@ export class ThreeDRoamManager {
|
||||
}
|
||||
this.removeTickCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,6 +105,7 @@
|
||||
:max="totalDuration"
|
||||
:marks="milestoneMarks"
|
||||
:tipFormatter="tipFormatter"
|
||||
@change="handleProgressChange"
|
||||
@afterChange="handleProgressChange"
|
||||
/>
|
||||
<div class="value-display">
|
||||
@ -188,6 +189,8 @@ async function initRoamManager(): Promise<void> {
|
||||
console.warn('Cesium viewer 未就绪');
|
||||
return;
|
||||
}
|
||||
// 进入漫游:锚点文字/图标开启深度测试,避免文字遮挡漫游模型(glb)
|
||||
MapClass.getInstance().setRoamDepthTest(true);
|
||||
// 进入漫游:保存当前相机状态用于退出时恢复
|
||||
savedCamera = {
|
||||
position: viewer.camera.position.clone(),
|
||||
@ -369,6 +372,12 @@ function handleFlightModelChange(value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度条拖动/点击时同步 Cesium 时钟并开启相机过渡
|
||||
* - jumpToTime:拖动/点击瞬间即同步时钟,避免播放中 onTick 进度回调按旧时钟位置
|
||||
* 把滑块值覆盖回去(问题1:10→20 先到 20 再回退到 10);
|
||||
* - flyToPositionAtTime:开启过渡窗口,让第一人称跟随平滑“飞”向目标位置(保留缓冲)。
|
||||
*/
|
||||
function handleProgressChange(value: number) {
|
||||
if (roamManager) {
|
||||
roamManager.jumpToTime(value);
|
||||
@ -382,6 +391,8 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 退出漫游:恢复锚点深度测试策略(按俯仰角动态切换)
|
||||
MapClass.getInstance().setRoamDepthTest(false);
|
||||
// dim 切换时跳过 Cesium 清理(由地图切换自行处理),避免影响 2D 地图初始化
|
||||
const skipCesiumCleanup = !!uiStore.skipRoamCameraRestore;
|
||||
roamManager?.destroy(skipCesiumCleanup);
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div id="map-controller" class="map-controller" :style="{ right: drawerOpen ? '480px' : '12px' }">
|
||||
<div
|
||||
id="map-controller"
|
||||
class="map-controller"
|
||||
:style="{ right: drawerOpen ? '480px' : '12px' }"
|
||||
>
|
||||
<div
|
||||
class="map-controller-group"
|
||||
v-for="(item, index) in controllers"
|
||||
@ -424,10 +428,10 @@ const handleControllerClick = (item: any) => {
|
||||
}
|
||||
break;
|
||||
case 'threedRoam': // 流域三维漫游
|
||||
if (is3DTransition.value) break;
|
||||
// 互斥:选中threedRoam时取消其他选中
|
||||
activeKey.value = activeKey.value === 'threedRoam' ? null : 'threedRoam';
|
||||
uiStore.isRoaming = !uiStore.isRoaming;
|
||||
// 进入/退出漫游立即同步深度测试与强制批量 popup,不依赖漫游面板的数据异步加载链路
|
||||
map.value.setRoamDepthTest?.(uiStore.isRoaming);
|
||||
break;
|
||||
// 可以在这里添加其他控制器的处理逻辑
|
||||
case 'screenShot':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user