优化2d和3d的地图展示 优化漫游,锚点添加淡入淡出效果

This commit is contained in:
扈兆增 2026-08-05 17:12:38 +08:00
parent ae2ff1ced4
commit 2622b9d511
12 changed files with 1190 additions and 777 deletions

View File

@ -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 />

View File

@ -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;

View File

@ -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

View File

@ -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();

View File

@ -213,6 +213,13 @@ export interface MapInterface {
checked: boolean
): void;
/**
* 3D
* /
* @param enabled
*/
setRoamDepthTest?(enabled: boolean): void;
/**
*
*/

View File

@ -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: &copy; OpenStreetMap contributors, SRTM | Map style: &copy; 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 &copy; Esri &mdash; 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();
// }
// }
// }
// }

View File

@ -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) {
// 淡入淡出 alphanull = 未在淡变中)
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 {
});
}
/**
* iconlabel/
* 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.00.0 0.01.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();
// fallbackcanvas 不可用时用简单估算(极少发生)
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) {

View File

@ -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`;
// 标记该锚点已有固定展示的 popuphover 时跳过 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;

View File

@ -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
* lookAtTransformcamera.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();
}
}
}

View File

@ -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 进度回调按旧时钟位置
* 把滑块值覆盖回去问题11020 先到 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);

View File

@ -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':