3031 lines
92 KiB
TypeScript
3031 lines
92 KiB
TypeScript
import { MapInterface } from './map';
|
||
import OlMap from 'ol/Map';
|
||
import View from 'ol/View';
|
||
import Overlay from 'ol/Overlay';
|
||
import TileLayer from 'ol/layer/Tile';
|
||
import VectorLayer from 'ol/layer/Vector';
|
||
import VectorSource from 'ol/source/Vector';
|
||
import GeoJSON from 'ol/format/GeoJSON';
|
||
import Style from 'ol/style/Style';
|
||
import Fill from 'ol/style/Fill';
|
||
import Stroke from 'ol/style/Stroke';
|
||
import Icon from 'ol/style/Icon';
|
||
import Text from 'ol/style/Text';
|
||
import WMTS from 'ol/source/WMTS';
|
||
import WMTSTileGrid from 'ol/tilegrid/WMTS';
|
||
import { get as getProjection, fromLonLat } from 'ol/proj';
|
||
import {
|
||
defaults as defaultInteractions,
|
||
Draw,
|
||
DoubleClickZoom,
|
||
DragPan
|
||
} from 'ol/interaction';
|
||
import { getTopLeft, getWidth } from 'ol/extent';
|
||
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom';
|
||
import { servers } from './mapurlManage';
|
||
import { XYZ } from 'ol/source';
|
||
import Feature from 'ol/Feature';
|
||
import LineString from 'ol/geom/LineString';
|
||
import Point from 'ol/geom/Point';
|
||
import {
|
||
getLength as getSphericalLength,
|
||
getArea as getSphericalArea
|
||
} from 'ol/sphere';
|
||
import { unByKey } from 'ol/Observable';
|
||
import { MDOptions } from './map.class';
|
||
import { useModelStore } from '@/store/modules/model';
|
||
import { PointLayerManager } from './ol/point-layer-manager';
|
||
import { PopupManager } from './ol/popup-manager';
|
||
import { RegionMaskManager } from './ol/region-mask-manager';
|
||
import { getNearbyPointDensityDisplayRules } from '@/modules/map/domain/nearby-point-rules';
|
||
import router from '@/router';
|
||
|
||
const modelStore = useModelStore();
|
||
const VITE_APP_MAP_URL = import.meta.env.VITE_APP_MAP_URL;
|
||
|
||
// 定义与 leaflet 中相同的常量
|
||
const CENTER_positionCN = [114.17112499999996, 38]; // OpenLayers 使用 [lon, lat]
|
||
const MIN_ZOOM = 4.23;
|
||
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;
|
||
// 栖息地锚点(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];
|
||
const BOUNDS_NE = [180.00000000000074, 60.06349386538693];
|
||
|
||
export class MapOl implements MapInterface {
|
||
map: OlMap | null = null;
|
||
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<
|
||
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';
|
||
private activeBaseLayerKey = 's_province_boundaries';
|
||
private readonly rasterBaseLayerKeys = ['BASEMAP-white', 'BASEMAP-img'];
|
||
private drawInteraction: any = null;
|
||
private measureLayer: VectorLayer | null = null;
|
||
private measureSource: VectorSource | null = null;
|
||
private pointLayerManager: PointLayerManager;
|
||
geoJsonData: any = null;
|
||
geoJsonData1: any = null;
|
||
private BASEID = '';
|
||
private currentClipGeoJson: any | null = null;
|
||
private clipRequestController: AbortController | null = null;
|
||
private wfsHighlightLayer: VectorLayer | null = null;
|
||
private popupManager: PopupManager;
|
||
private regionMaskManager: RegionMaskManager;
|
||
private isBatchPopupMode = false;
|
||
private batchPopupRefreshFrameId: number | null = null;
|
||
private batchPopupPendingRebuild = false;
|
||
private batchPopupPostRenderListenerKey: any = null;
|
||
private pointLayerStyleRefreshFrameId: number | null = null;
|
||
private labelVisibilityRefreshFrameId: number | null = null;
|
||
private labelVisibilityDebounceTimerId: number | null = null;
|
||
constructor() {
|
||
this.pointLayerManager = new PointLayerManager({
|
||
map: null,
|
||
createStyle: feature => this.createPointStyle(feature)
|
||
});
|
||
this.popupManager = new PopupManager({
|
||
map: null,
|
||
getPointLayers: () => this.pointLayerManager.getLayers()
|
||
});
|
||
this.regionMaskManager = new RegionMaskManager({
|
||
map: null,
|
||
view: null,
|
||
pointLayerManager: this.pointLayerManager,
|
||
defaultCenter: CENTER_positionCN as [number, number],
|
||
defaultZoom: INITIAL_ZOOM
|
||
});
|
||
}
|
||
private async loadGeoJsonData(url, signal?: AbortSignal): Promise<any> {
|
||
try {
|
||
const response = await fetch(url, { signal });
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error: any) {
|
||
if (error.name !== 'AbortError') {
|
||
console.error('配置加载失败:', error);
|
||
}
|
||
}
|
||
}
|
||
//地图初始化
|
||
init(container: HTMLElement): Promise<any> {
|
||
try {
|
||
const minCoordinate = fromLonLat(BOUNDS_SW);
|
||
const maxCoordinate = fromLonLat(BOUNDS_NE);
|
||
const extent = [
|
||
minCoordinate[0],
|
||
minCoordinate[1],
|
||
maxCoordinate[0],
|
||
maxCoordinate[1]
|
||
];
|
||
|
||
this.view = new View({
|
||
center: fromLonLat(CENTER_positionCN), // 设置中心点
|
||
zoom: INITIAL_ZOOM,
|
||
minZoom: MIN_ZOOM,
|
||
maxZoom: MAX_ZOOM,
|
||
constrainOnlyCenter: false, // 允许部分视图超出边界,但中心点受限(类似 Leaflet 默认行为)
|
||
extent: extent, // 限制视图范围
|
||
smoothExtentConstraint: false // 平滑边界约束
|
||
});
|
||
|
||
const mouseWheelInteraction = new MouseWheelZoom({
|
||
duration: 100, // 缩放动画持续时间 (ms),Leaflet 默认也有动画
|
||
maxDelta: 2,
|
||
constrainResolution: false // ✅ 关键:false 允许缩放到非整数级别 (如 4.5, 4.6),实现平滑缩放
|
||
});
|
||
|
||
this.map = new OlMap({
|
||
target: container,
|
||
layers: [],
|
||
view: this.view,
|
||
controls: [], // 对应 Leaflet 的 attributionControl: false, zoomControl: false
|
||
interactions: defaultInteractions({
|
||
doubleClickZoom: true,
|
||
dragPan: false,
|
||
|
||
pinchRotate: false // 通常禁用旋转,除非需要
|
||
}).extend([
|
||
new DragPan({
|
||
kinetic: undefined
|
||
}),
|
||
mouseWheelInteraction
|
||
])
|
||
});
|
||
this.pointLayerManager.setMap(this.map);
|
||
this.popupManager.setMap(this.map);
|
||
this.regionMaskManager.setContext(this.map, this.view);
|
||
|
||
// WFS 河段悬停高亮图层:与 demo 项目保持一致,命中要素复制到该图层以加深色描边高亮
|
||
this.wfsHighlightLayer = new VectorLayer({
|
||
source: new VectorSource(),
|
||
style: new Style({
|
||
stroke: new Stroke({
|
||
color: '#FFFF00',
|
||
width: 2
|
||
})
|
||
}),
|
||
zIndex: 20
|
||
});
|
||
this.map.addLayer(this.wfsHighlightLayer);
|
||
|
||
this.view.on('change:resolution', () => {
|
||
this.handleZoomChange();
|
||
});
|
||
this.map.on('click', evt => {
|
||
if (
|
||
router.currentRoute.value.path.includes(
|
||
'shengTaiLiuLiangManZuQingKuangJiangJu'
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
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_;
|
||
} else {
|
||
modelStore.modalVisible = true;
|
||
modelStore.params = detectedFeature.values_;
|
||
modelStore.title =
|
||
detectedFeature.values_.titleName ||
|
||
detectedFeature.values_.stnm + ' 详情信息';
|
||
}
|
||
});
|
||
});
|
||
|
||
this.map.on('pointermove', evt => {
|
||
// fh_qxd 河段悬停高亮
|
||
const hitFeature = this.map.forEachFeatureAtPixel(
|
||
evt.pixel,
|
||
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();
|
||
const highlightFeature = anchorLinkedFeature || hitFeature;
|
||
if (highlightFeature) {
|
||
this.wfsHighlightLayer.setStyle(
|
||
new Style({
|
||
stroke: new Stroke({
|
||
color: '#37ff00ff',
|
||
width: 4
|
||
})
|
||
})
|
||
);
|
||
highlightSource?.clear();
|
||
highlightSource?.addFeature(highlightFeature);
|
||
} else {
|
||
highlightSource?.clear();
|
||
}
|
||
}
|
||
|
||
this.popupManager.handlePointerMove(evt.pixel, payload => {
|
||
if (targetElement) {
|
||
// 命中河段或 hover popup 命中锚点时显示 pointer 光标
|
||
targetElement.style.cursor =
|
||
anchorDetect.isHitIcon || payload.hoveredId || hitFeature
|
||
? 'pointer'
|
||
: '';
|
||
}
|
||
|
||
// 该锚点已有固定展示(批量 popup)的 popup,跳过 hover popup,避免同一内容出现双份;
|
||
// 未被固定展示的锚点(如碰撞被隐藏)仍可通过 hover 查看,且 hover popup 展示时会临时提升层级,不被固定展示遮挡
|
||
const fixedShown =
|
||
payload.detectedFeature?.get('_fixedPopupShown') === true;
|
||
this.showPopup(
|
||
fixedShown ? undefined : payload.detectedFeature,
|
||
fixedShown ? undefined : payload.coordinate
|
||
);
|
||
});
|
||
});
|
||
|
||
this.map.on('pointerdrag', () => {
|
||
if (this.isBatchPopupMode) {
|
||
this.ensureBatchPopupPostRenderSync();
|
||
}
|
||
});
|
||
this.map.on('moveend', () => {
|
||
this.clearBatchPopupPostRenderSync();
|
||
this.requestRefreshPointLabelVisibility(false, 60);
|
||
if (this.isBatchPopupMode) {
|
||
this.requestUpdateBatchPopups(false, true);
|
||
}
|
||
});
|
||
|
||
return Promise.resolve(this.map);
|
||
} catch (e) {
|
||
console.error('OL Init Error', e);
|
||
return Promise.reject(e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化加载描点数据
|
||
* @param pointData 图层配置对象或数据数组
|
||
* @param layerType 图层类型/Key
|
||
* @param mdoptions 描点选项配置
|
||
*/
|
||
addInitDataLayer(
|
||
pointData: any,
|
||
layerType: any,
|
||
_mdoptions?: MDOptions
|
||
): void {
|
||
this.pointLayerManager.addDataLayer(pointData, layerType);
|
||
|
||
if (this.currentClipGeoJson) {
|
||
this.regionMaskManager.filterPointsByRegion(this.currentClipGeoJson);
|
||
}
|
||
|
||
const currentZoom = this.view ? this.view.getZoom() : INITIAL_ZOOM;
|
||
this.pointLayerManager.updateNearbyFeatureLayout(currentZoom);
|
||
this.requestRefreshPointLabelVisibility(false, 80);
|
||
}
|
||
/**
|
||
* 初始化 Popup Overlay
|
||
* @param container 父组件传入的 DOM 容器
|
||
*/
|
||
initPopupOverlay(container: HTMLElement) {
|
||
this.popupManager.initPopupOverlay(container);
|
||
}
|
||
/**
|
||
* 在指定像素位置检测要素(共享方法)
|
||
* @param pixel 像素坐标
|
||
* @returns 检测到的要素和是否命中图标区域
|
||
*/
|
||
private detectFeatureAtPixel(pixel: number[]): {
|
||
detectedFeature: Feature | undefined;
|
||
isHitIcon: boolean;
|
||
coordinate?: number[];
|
||
} {
|
||
return this.popupManager.detectFeatureAtPixel(pixel);
|
||
}
|
||
|
||
/**
|
||
* 在 pointermove 中调用此方法显示/隐藏 Popup
|
||
* @param feature 当前悬停的要素
|
||
* @param coordinate 地理坐标
|
||
*/
|
||
showPopup(feature: Feature | undefined, coordinate: number[] | undefined) {
|
||
this.popupManager.showPopup(feature, coordinate);
|
||
}
|
||
|
||
/**
|
||
* 监听缩放层级变化,到达指定层级时自动显示可视区域内所有锚点的 popup
|
||
*/
|
||
private handleZoomChange() {
|
||
if (!this.view) return;
|
||
|
||
const zoom = this.view.getZoom();
|
||
if (zoom === undefined) return;
|
||
|
||
this.pointLayerManager.updateNearbyFeatureLayout(zoom);
|
||
this.requestRefreshPointLabelVisibility();
|
||
|
||
if (zoom >= BATCH_POPUP_MODE_ZOOM) {
|
||
this.enableBatchPopupMode();
|
||
} else {
|
||
this.disableBatchPopupMode();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 启用批量 popup 模式:自动显示可视区域内所有锚点的 popup
|
||
*/
|
||
private enableBatchPopupMode() {
|
||
this.isBatchPopupMode = true;
|
||
this.clearBatchPopupPostRenderSync();
|
||
this.popupManager.clearBatchPopups();
|
||
this.requestUpdateBatchPopups(true, true);
|
||
}
|
||
|
||
/**
|
||
* 禁用批量 popup 模式:恢复鼠标悬停显示 popup
|
||
*/
|
||
private disableBatchPopupMode() {
|
||
this.isBatchPopupMode = false;
|
||
this.batchPopupPendingRebuild = false;
|
||
this.clearBatchPopupPostRenderSync();
|
||
if (this.batchPopupRefreshFrameId !== null) {
|
||
cancelAnimationFrame(this.batchPopupRefreshFrameId);
|
||
this.batchPopupRefreshFrameId = null;
|
||
}
|
||
this.popupManager.clearBatchPopups();
|
||
this.popupManager.showPopup(undefined, undefined);
|
||
}
|
||
|
||
/**
|
||
* 更新批量显示的 popups(拖动地图后调用)
|
||
*/
|
||
private updateBatchPopups() {
|
||
const features = this.pointLayerManager.getFeaturesInViewport();
|
||
// 传入样式检查器,过滤掉 declutter/距离过滤等隐藏的锚点
|
||
this.popupManager.showPopupsForFeatures(
|
||
features,
|
||
feature => {
|
||
const style = this.createPointStyle(feature);
|
||
return style !== null;
|
||
},
|
||
{
|
||
rebuild: this.batchPopupPendingRebuild
|
||
}
|
||
);
|
||
}
|
||
|
||
private ensureBatchPopupPostRenderSync() {
|
||
if (!this.map || this.batchPopupPostRenderListenerKey) {
|
||
return;
|
||
}
|
||
|
||
this.batchPopupPostRenderListenerKey = this.map.on('postrender', () => {
|
||
if (!this.isBatchPopupMode) {
|
||
return;
|
||
}
|
||
this.popupManager.updateBatchPopupPositions(feature => {
|
||
const style = this.createPointStyle(feature);
|
||
return style !== null;
|
||
});
|
||
});
|
||
}
|
||
|
||
private clearBatchPopupPostRenderSync() {
|
||
if (this.batchPopupPostRenderListenerKey) {
|
||
unByKey(this.batchPopupPostRenderListenerKey);
|
||
this.batchPopupPostRenderListenerKey = null;
|
||
}
|
||
}
|
||
/**
|
||
* 创建点样式 (模拟 Leaflet 的 DivIcon 效果,支持随缩放动态调整大小)
|
||
*/
|
||
private createPointStyle(feature: Feature): Style[] | null {
|
||
const iconUrl = feature.get('_iconUrl') as string;
|
||
const labelText = feature.get('_labelText') as string;
|
||
const legendVisible = feature.get('_legendVisible');
|
||
const regionVisible = feature.get('_regionVisible');
|
||
|
||
if (!iconUrl) {
|
||
return null;
|
||
}
|
||
|
||
if (!this.ensureIconReady(iconUrl)) {
|
||
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 =
|
||
typeof cachedDensityVisible === 'boolean'
|
||
? cachedDensityVisible
|
||
: currentZoom >= this.getFeatureDensityMinZoom(feature);
|
||
const iconTargetVisible =
|
||
legendVisible !== false &&
|
||
regionVisible !== false &&
|
||
densityVisible &&
|
||
this.shouldRenderNearbyFeature();
|
||
|
||
const dynamicScale = this.getDynamicPointScale(currentZoom);
|
||
const fontSize = this.getPointFontSize(dynamicScale);
|
||
const engPriority = this.getFeatureEngRenderPriority(feature);
|
||
const formattedLabelText = this.formatPointLabelText(labelText);
|
||
const labelLineCount = formattedLabelText
|
||
? formattedLabelText.split('\n').length
|
||
: 1;
|
||
const labelOffsetY = this.getPointLabelRenderOffsetY(
|
||
dynamicScale,
|
||
labelLineCount
|
||
);
|
||
const iconCollisionVisible = feature.get('_iconCollisionVisible') !== false;
|
||
const labelCollisionVisible =
|
||
feature.get('_labelCollisionVisible') === true;
|
||
|
||
// 淡入淡出 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,
|
||
labelRenderVisible ? formattedLabelText : '',
|
||
labelRenderVisible ? 1 : 0,
|
||
fading ? 1 : 0
|
||
].join('|');
|
||
const cachedStyles = this.pointStyleCache.get(styleCacheKey);
|
||
if (cachedStyles) {
|
||
return cachedStyles;
|
||
}
|
||
|
||
const styles: Style[] = [
|
||
new Style({
|
||
zIndex: engPriority,
|
||
image: new Icon({
|
||
src: iconUrl,
|
||
scale: dynamicScale,
|
||
anchor: [0.5, 0.5],
|
||
crossOrigin: 'anonymous',
|
||
declutterMode: 'none',
|
||
opacity: iconRenderAlpha
|
||
})
|
||
})
|
||
];
|
||
|
||
if (formattedLabelText && labelRenderVisible) {
|
||
styles.push(
|
||
new Style({
|
||
zIndex: engPriority + 1,
|
||
text: new Text({
|
||
text: formattedLabelText,
|
||
offsetY: labelOffsetY,
|
||
font: `${fontSize}px sans-serif`,
|
||
fill: new Fill({
|
||
color: `rgba(255, 255, 255, ${effectiveLabelAlpha})`
|
||
}),
|
||
stroke: new Stroke({
|
||
color: `rgba(0, 0, 0, ${0.9 * effectiveLabelAlpha})`,
|
||
width: 2
|
||
}),
|
||
textAlign: 'center',
|
||
declutterMode: 'none'
|
||
})
|
||
})
|
||
);
|
||
}
|
||
|
||
if (!fading) {
|
||
this.pointStyleCache.set(styleCacheKey, styles);
|
||
}
|
||
return styles;
|
||
}
|
||
|
||
private markFeatureVisibilityDirty(
|
||
feature: Feature,
|
||
key: '_densityVisible' | '_iconCollisionVisible' | '_labelCollisionVisible',
|
||
visible: boolean,
|
||
dirtyLayerKeys?: Set<string>
|
||
) {
|
||
const prev = feature.get(key);
|
||
if (prev === visible) {
|
||
return;
|
||
}
|
||
|
||
feature.set(key, visible, true);
|
||
const layerKey = feature.get('_layerKey');
|
||
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>) {
|
||
dirtyLayerKeys.forEach(layerKey => {
|
||
const layer = this.pointLayerManager.getLayer(layerKey);
|
||
layer?.changed();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 启动锚点(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,
|
||
dirtyLayerKeys?: Set<string>
|
||
) {
|
||
this.markFeatureVisibilityDirty(
|
||
feature,
|
||
'_densityVisible',
|
||
visible,
|
||
dirtyLayerKeys
|
||
);
|
||
}
|
||
|
||
private getFeaturePixelGridKey(
|
||
layerKey: string,
|
||
pixelX: number,
|
||
pixelY: number,
|
||
cellSize: number
|
||
) {
|
||
return `${layerKey}:${Math.floor(pixelX / cellSize)}:${Math.floor(
|
||
pixelY / cellSize
|
||
)}`;
|
||
}
|
||
|
||
private hasNearbyViewportFeature(
|
||
target: {
|
||
feature: Feature;
|
||
layerKey: string;
|
||
pixelX: number;
|
||
pixelY: number;
|
||
},
|
||
pixelGrid: Map<
|
||
string,
|
||
Array<{
|
||
feature: Feature;
|
||
layerKey: string;
|
||
pixelX: number;
|
||
pixelY: number;
|
||
}>
|
||
>,
|
||
threshold: number
|
||
): boolean {
|
||
const cellX = Math.floor(target.pixelX / threshold);
|
||
const cellY = Math.floor(target.pixelY / threshold);
|
||
|
||
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
|
||
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
|
||
const bucket = pixelGrid.get(
|
||
`${target.layerKey}:${cellX + offsetX}:${cellY + offsetY}`
|
||
);
|
||
if (!bucket?.length) {
|
||
continue;
|
||
}
|
||
|
||
for (const candidate of bucket) {
|
||
if (candidate.feature === target.feature) {
|
||
continue;
|
||
}
|
||
if (
|
||
Math.hypot(
|
||
candidate.pixelX - target.pixelX,
|
||
candidate.pixelY - target.pixelY
|
||
) <= threshold
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private getCollisionGridKeys(
|
||
rect: { left: number; right: number; top: number; bottom: number },
|
||
cellSize: number
|
||
): string[] {
|
||
const startX = Math.floor(rect.left / cellSize);
|
||
const endX = Math.floor(rect.right / cellSize);
|
||
const startY = Math.floor(rect.top / cellSize);
|
||
const endY = Math.floor(rect.bottom / cellSize);
|
||
const keys: string[] = [];
|
||
|
||
for (let x = startX; x <= endX; x += 1) {
|
||
for (let y = startY; y <= endY; y += 1) {
|
||
keys.push(`${x}:${y}`);
|
||
}
|
||
}
|
||
|
||
return keys;
|
||
}
|
||
|
||
private addRectToCollisionGrid<
|
||
T extends {
|
||
left: number;
|
||
right: number;
|
||
top: number;
|
||
bottom: number;
|
||
}
|
||
>(rect: T, grid: Map<string, T[]>, cellSize: number) {
|
||
this.getCollisionGridKeys(rect, cellSize).forEach(key => {
|
||
const bucket = grid.get(key) || [];
|
||
bucket.push(rect);
|
||
grid.set(key, bucket);
|
||
});
|
||
}
|
||
|
||
private findCollidingRectInGrid<
|
||
T extends {
|
||
left: number;
|
||
right: number;
|
||
top: number;
|
||
bottom: number;
|
||
}
|
||
>(
|
||
rect: { left: number; right: number; top: number; bottom: number },
|
||
grid: Map<string, T[]>,
|
||
cellSize: number,
|
||
isCollision: (candidate: T) => boolean
|
||
): T | undefined {
|
||
for (const key of this.getCollisionGridKeys(rect, cellSize)) {
|
||
const bucket = grid.get(key);
|
||
if (!bucket?.length) {
|
||
continue;
|
||
}
|
||
|
||
for (const candidate of bucket) {
|
||
if (isCollision(candidate)) {
|
||
return candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
private refreshPointLabelVisibility() {
|
||
if (!this.map || !this.view) return;
|
||
|
||
const currentZoom = this.view.getZoom();
|
||
if (currentZoom === undefined) return;
|
||
|
||
const candidateMap = new Map<
|
||
string,
|
||
{
|
||
feature: Feature;
|
||
iconLeft: number;
|
||
iconRight: number;
|
||
iconTop: number;
|
||
iconBottom: number;
|
||
hasLabel: boolean;
|
||
left: number;
|
||
right: number;
|
||
top: number;
|
||
bottom: number;
|
||
priority: number;
|
||
engPriority: number;
|
||
densityPriority: number;
|
||
id: string;
|
||
pixelX: number;
|
||
pixelY: number;
|
||
}
|
||
>();
|
||
const candidates: Array<{
|
||
feature: Feature;
|
||
iconLeft: number;
|
||
iconRight: number;
|
||
iconTop: number;
|
||
iconBottom: number;
|
||
hasLabel: boolean;
|
||
left: number;
|
||
right: number;
|
||
top: number;
|
||
bottom: number;
|
||
priority: number;
|
||
engPriority: number;
|
||
densityPriority: number;
|
||
id: string;
|
||
pixelX: number;
|
||
pixelY: number;
|
||
}> = [];
|
||
|
||
const viewportFeatures = this.pointLayerManager.getFeaturesInViewport();
|
||
const dirtyLayerKeys = new Set<string>();
|
||
const densityIsolationThreshold = 56;
|
||
const densityPixelGrid = new Map<
|
||
string,
|
||
Array<{
|
||
feature: Feature;
|
||
layerKey: string;
|
||
pixelX: number;
|
||
pixelY: number;
|
||
}>
|
||
>();
|
||
const viewportEntries: Array<{
|
||
feature: Feature;
|
||
layerKey: string;
|
||
iconUrl: string;
|
||
legendVisible: boolean;
|
||
regionVisible: boolean;
|
||
densityPriority: number;
|
||
nearbyVisible: boolean;
|
||
labelText: string;
|
||
pixelX?: number;
|
||
pixelY?: number;
|
||
}> = [];
|
||
|
||
viewportFeatures.forEach(feature => {
|
||
const geometry = feature.getGeometry();
|
||
const pixel =
|
||
geometry && geometry.getType() === 'Point'
|
||
? this.map?.getPixelFromCoordinate(
|
||
(geometry as Point).getCoordinates()
|
||
)
|
||
: null;
|
||
const layerKey = String(feature.get('_layerKey') || '');
|
||
const entry = {
|
||
feature,
|
||
layerKey,
|
||
iconUrl: String(feature.get('_iconUrl') || ''),
|
||
legendVisible: feature.get('_legendVisible') !== false,
|
||
regionVisible: feature.get('_regionVisible') !== false,
|
||
densityPriority: this.getFeatureDensityPriority(feature),
|
||
nearbyVisible: this.shouldRenderNearbyFeature(),
|
||
labelText: this.formatPointLabelText(
|
||
feature.get('_labelText') as string
|
||
),
|
||
pixelX: pixel?.[0],
|
||
pixelY: pixel?.[1]
|
||
};
|
||
viewportEntries.push(entry);
|
||
|
||
if (
|
||
pixel &&
|
||
entry.layerKey &&
|
||
entry.legendVisible &&
|
||
entry.regionVisible
|
||
) {
|
||
const gridKey = this.getFeaturePixelGridKey(
|
||
entry.layerKey,
|
||
pixel[0],
|
||
pixel[1],
|
||
densityIsolationThreshold
|
||
);
|
||
const bucket = densityPixelGrid.get(gridKey) || [];
|
||
bucket.push({
|
||
feature,
|
||
layerKey: entry.layerKey,
|
||
pixelX: pixel[0],
|
||
pixelY: pixel[1]
|
||
});
|
||
densityPixelGrid.set(gridKey, bucket);
|
||
}
|
||
});
|
||
|
||
if (currentZoom >= FULL_DISPLAY_NO_COLLISION_ZOOM) {
|
||
viewportEntries.forEach(entry => {
|
||
const minZoom = this.getFeatureDensityMinZoom(entry.feature);
|
||
const densityVisible =
|
||
currentZoom >= minZoom ||
|
||
(entry.legendVisible &&
|
||
entry.regionVisible &&
|
||
!!entry.layerKey &&
|
||
entry.pixelX !== undefined &&
|
||
entry.pixelY !== undefined &&
|
||
!this.hasNearbyViewportFeature(
|
||
{
|
||
feature: entry.feature,
|
||
layerKey: entry.layerKey,
|
||
pixelX: entry.pixelX,
|
||
pixelY: entry.pixelY
|
||
},
|
||
densityPixelGrid,
|
||
densityIsolationThreshold
|
||
));
|
||
this.syncFeatureDensityVisible(
|
||
entry.feature,
|
||
densityVisible,
|
||
dirtyLayerKeys
|
||
);
|
||
const shouldRenderIcon =
|
||
!!entry.iconUrl &&
|
||
this.isIconReady(entry.iconUrl) &&
|
||
entry.legendVisible &&
|
||
entry.regionVisible &&
|
||
densityVisible &&
|
||
entry.nearbyVisible;
|
||
const hasLabel = !!entry.labelText;
|
||
|
||
this.syncFeatureIconCollisionVisible(
|
||
entry.feature,
|
||
shouldRenderIcon,
|
||
dirtyLayerKeys
|
||
);
|
||
this.syncFeatureLabelCollisionVisible(
|
||
entry.feature,
|
||
shouldRenderIcon && hasLabel,
|
||
dirtyLayerKeys
|
||
);
|
||
});
|
||
this.flushDirtyPointLayers(dirtyLayerKeys);
|
||
return;
|
||
}
|
||
|
||
viewportEntries.forEach(entry => {
|
||
const minZoom = this.getFeatureDensityMinZoom(entry.feature);
|
||
const densityVisible =
|
||
currentZoom >= minZoom ||
|
||
(entry.legendVisible &&
|
||
entry.regionVisible &&
|
||
!!entry.layerKey &&
|
||
entry.pixelX !== undefined &&
|
||
entry.pixelY !== undefined &&
|
||
!this.hasNearbyViewportFeature(
|
||
{
|
||
feature: entry.feature,
|
||
layerKey: entry.layerKey,
|
||
pixelX: entry.pixelX,
|
||
pixelY: entry.pixelY
|
||
},
|
||
densityPixelGrid,
|
||
densityIsolationThreshold
|
||
));
|
||
this.syncFeatureDensityVisible(
|
||
entry.feature,
|
||
densityVisible,
|
||
dirtyLayerKeys
|
||
);
|
||
|
||
const shouldRenderIcon =
|
||
!!entry.iconUrl &&
|
||
this.isIconReady(entry.iconUrl) &&
|
||
entry.legendVisible &&
|
||
entry.regionVisible &&
|
||
densityVisible &&
|
||
entry.nearbyVisible;
|
||
|
||
if (!shouldRenderIcon) {
|
||
this.syncFeatureIconCollisionVisible(
|
||
entry.feature,
|
||
false,
|
||
dirtyLayerKeys
|
||
);
|
||
this.syncFeatureLabelCollisionVisible(
|
||
entry.feature,
|
||
false,
|
||
dirtyLayerKeys
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (entry.pixelX === undefined || entry.pixelY === undefined) {
|
||
this.syncFeatureIconCollisionVisible(
|
||
entry.feature,
|
||
false,
|
||
dirtyLayerKeys
|
||
);
|
||
this.syncFeatureLabelCollisionVisible(
|
||
entry.feature,
|
||
false,
|
||
dirtyLayerKeys
|
||
);
|
||
return;
|
||
}
|
||
|
||
const dynamicScale = this.getDynamicPointScale(currentZoom);
|
||
// 备注:图标碰撞盒适当小于视觉图标尺寸,允许相邻站点轻微贴近显示,
|
||
// 避免像“小浪底/三门峡”这类近点被过早裁掉成只剩一个点。
|
||
const iconCollisionSize = Math.max(14, 24 * dynamicScale);
|
||
const iconPadding = 0;
|
||
const iconLeft = entry.pixelX - iconCollisionSize / 2 - iconPadding;
|
||
const iconRight = entry.pixelX + iconCollisionSize / 2 + iconPadding;
|
||
const iconTop = entry.pixelY - iconCollisionSize / 2 - iconPadding;
|
||
const iconBottom = entry.pixelY + iconCollisionSize / 2 + iconPadding;
|
||
const labelText = entry.labelText;
|
||
const fontSize = this.getPointFontSize(dynamicScale);
|
||
const labelLineCount = labelText ? labelText.split('\n').length : 1;
|
||
// 碰撞偏移与渲染偏移统一,保证碰撞块与实际渲染文字完全重合
|
||
const labelOffsetY = this.getPointLabelRenderOffsetY(
|
||
dynamicScale,
|
||
labelLineCount
|
||
);
|
||
// 碰撞块大小 = 文字实测像素大小(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(
|
||
entry.feature.getId?.() || entry.feature.get('stcd') || ''
|
||
);
|
||
const dedupeKey =
|
||
candidateId ||
|
||
[
|
||
entry.feature.get('_layerKey') || '',
|
||
entry.pixelX.toFixed(2),
|
||
entry.pixelY.toFixed(2),
|
||
labelText
|
||
].join('|');
|
||
|
||
candidateMap.set(dedupeKey, {
|
||
feature: entry.feature,
|
||
iconLeft,
|
||
iconRight,
|
||
iconTop,
|
||
iconBottom,
|
||
hasLabel: !!labelText,
|
||
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,
|
||
id: candidateId,
|
||
pixelX: entry.pixelX,
|
||
pixelY: entry.pixelY
|
||
});
|
||
});
|
||
|
||
candidateMap.forEach(candidate => {
|
||
candidates.push(candidate);
|
||
});
|
||
|
||
candidates.sort((left, right) => {
|
||
if (left.engPriority !== right.engPriority) {
|
||
return right.engPriority - left.engPriority;
|
||
}
|
||
if (left.priority !== right.priority) {
|
||
return left.priority - right.priority;
|
||
}
|
||
if (left.densityPriority !== right.densityPriority) {
|
||
return left.densityPriority - right.densityPriority;
|
||
}
|
||
if (left.pixelY !== right.pixelY) {
|
||
return left.pixelY - right.pixelY;
|
||
}
|
||
return left.id.localeCompare(right.id);
|
||
});
|
||
|
||
const placedIconRects: Array<{
|
||
featureId: string;
|
||
left: number;
|
||
right: number;
|
||
top: number;
|
||
bottom: number;
|
||
}> = [];
|
||
const placedIconGrid = new Map<
|
||
string,
|
||
Array<{
|
||
featureId: string;
|
||
left: number;
|
||
right: number;
|
||
top: number;
|
||
bottom: number;
|
||
}>
|
||
>();
|
||
const placedLabelRects: Array<{
|
||
featureId: string;
|
||
left: number;
|
||
right: number;
|
||
top: number;
|
||
bottom: number;
|
||
}> = [];
|
||
const placedLabelGrid = new Map<
|
||
string,
|
||
Array<{
|
||
featureId: string;
|
||
left: number;
|
||
right: number;
|
||
top: number;
|
||
bottom: number;
|
||
}>
|
||
>();
|
||
const collisionGridCellSize = 96;
|
||
|
||
candidates.forEach(candidate => {
|
||
const featureId = String(
|
||
candidate.feature.getId?.() || candidate.feature.get('stcd') || ''
|
||
);
|
||
const iconRect = {
|
||
left: candidate.iconLeft,
|
||
right: candidate.iconRight,
|
||
top: candidate.iconTop,
|
||
bottom: candidate.iconBottom
|
||
};
|
||
const iconHasCollision = !!this.findCollidingRectInGrid(
|
||
iconRect,
|
||
placedIconGrid,
|
||
collisionGridCellSize,
|
||
rect => this.checkLabelCollision(rect, iconRect)
|
||
);
|
||
const iconVisible = !iconHasCollision;
|
||
this.syncFeatureIconCollisionVisible(
|
||
candidate.feature,
|
||
iconVisible,
|
||
dirtyLayerKeys
|
||
);
|
||
|
||
if (iconVisible) {
|
||
placedIconRects.push({
|
||
featureId,
|
||
left: iconRect.left,
|
||
right: iconRect.right,
|
||
top: iconRect.top,
|
||
bottom: iconRect.bottom
|
||
});
|
||
this.addRectToCollisionGrid(
|
||
placedIconRects[placedIconRects.length - 1],
|
||
placedIconGrid,
|
||
collisionGridCellSize
|
||
);
|
||
}
|
||
});
|
||
|
||
const labelCandidates = [...candidates].sort((left, right) => {
|
||
if (left.engPriority !== right.engPriority) {
|
||
return right.engPriority - left.engPriority;
|
||
}
|
||
if (left.priority !== right.priority) {
|
||
return left.priority - right.priority;
|
||
}
|
||
if (left.pixelY !== right.pixelY) {
|
||
return left.pixelY - right.pixelY;
|
||
}
|
||
if (left.pixelX !== right.pixelX) {
|
||
return left.pixelX - right.pixelX;
|
||
}
|
||
return left.id.localeCompare(right.id);
|
||
});
|
||
|
||
labelCandidates.forEach(candidate => {
|
||
const featureId = String(
|
||
candidate.feature.getId?.() || candidate.feature.get('stcd') || ''
|
||
);
|
||
const iconVisible =
|
||
candidate.feature.get('_iconCollisionVisible') !== false;
|
||
if (!candidate.hasLabel) {
|
||
this.syncFeatureLabelCollisionVisible(
|
||
candidate.feature,
|
||
false,
|
||
dirtyLayerKeys
|
||
);
|
||
return;
|
||
}
|
||
if (!iconVisible) {
|
||
this.syncFeatureLabelCollisionVisible(
|
||
candidate.feature,
|
||
false,
|
||
dirtyLayerKeys
|
||
);
|
||
return;
|
||
}
|
||
|
||
const collidingIconRect = this.findCollidingRectInGrid(
|
||
candidate,
|
||
placedIconGrid,
|
||
collisionGridCellSize,
|
||
rect =>
|
||
rect.featureId !== featureId &&
|
||
this.checkLabelCollisionWithIcon(rect, candidate)
|
||
);
|
||
const hasCollisionWithIcon = !!collidingIconRect;
|
||
const collidingLabelRect = this.findCollidingRectInGrid(
|
||
candidate,
|
||
placedLabelGrid,
|
||
collisionGridCellSize,
|
||
rect => this.checkLabelCollision(rect, candidate)
|
||
);
|
||
const hasCollisionWithLabel = !!collidingLabelRect;
|
||
const visible = !hasCollisionWithIcon && !hasCollisionWithLabel;
|
||
|
||
this.syncFeatureLabelCollisionVisible(
|
||
candidate.feature,
|
||
visible,
|
||
dirtyLayerKeys
|
||
);
|
||
|
||
if (visible) {
|
||
placedLabelRects.push({
|
||
featureId,
|
||
left: candidate.left,
|
||
right: candidate.right,
|
||
top: candidate.top,
|
||
bottom: candidate.bottom
|
||
});
|
||
this.addRectToCollisionGrid(
|
||
placedLabelRects[placedLabelRects.length - 1],
|
||
placedLabelGrid,
|
||
collisionGridCellSize
|
||
);
|
||
}
|
||
});
|
||
this.flushDirtyPointLayers(dirtyLayerKeys);
|
||
}
|
||
|
||
private requestRefreshPointLabelVisibility(
|
||
immediate = false,
|
||
debounceMs = 0
|
||
) {
|
||
if (this.labelVisibilityDebounceTimerId !== null) {
|
||
window.clearTimeout(this.labelVisibilityDebounceTimerId);
|
||
this.labelVisibilityDebounceTimerId = null;
|
||
}
|
||
|
||
if (immediate) {
|
||
if (this.labelVisibilityRefreshFrameId !== null) {
|
||
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
|
||
this.labelVisibilityRefreshFrameId = null;
|
||
}
|
||
this.refreshPointLabelVisibility();
|
||
return;
|
||
}
|
||
|
||
if (debounceMs > 0) {
|
||
this.labelVisibilityDebounceTimerId = window.setTimeout(() => {
|
||
this.labelVisibilityDebounceTimerId = null;
|
||
this.requestRefreshPointLabelVisibility();
|
||
}, debounceMs);
|
||
return;
|
||
}
|
||
|
||
if (this.labelVisibilityRefreshFrameId !== null) {
|
||
return;
|
||
}
|
||
|
||
this.labelVisibilityRefreshFrameId = window.requestAnimationFrame(() => {
|
||
this.labelVisibilityRefreshFrameId = null;
|
||
this.refreshPointLabelVisibility();
|
||
});
|
||
}
|
||
|
||
private syncFeatureIconCollisionVisible(
|
||
feature: Feature,
|
||
visible: boolean,
|
||
dirtyLayerKeys?: Set<string>
|
||
) {
|
||
this.markFeatureVisibilityDirty(
|
||
feature,
|
||
'_iconCollisionVisible',
|
||
visible,
|
||
dirtyLayerKeys
|
||
);
|
||
}
|
||
|
||
private syncFeatureLabelCollisionVisible(
|
||
feature: Feature,
|
||
visible: boolean,
|
||
dirtyLayerKeys?: Set<string>
|
||
) {
|
||
this.markFeatureVisibilityDirty(
|
||
feature,
|
||
'_labelCollisionVisible',
|
||
visible,
|
||
dirtyLayerKeys
|
||
);
|
||
}
|
||
|
||
private isIconReady(iconUrl: string): boolean {
|
||
return this.iconLoadState.get(iconUrl) === 'loaded';
|
||
}
|
||
|
||
private ensureIconReady(iconUrl: string): boolean {
|
||
if (!iconUrl) {
|
||
return false;
|
||
}
|
||
|
||
const status = this.iconLoadState.get(iconUrl);
|
||
if (status === 'loaded') {
|
||
return true;
|
||
}
|
||
if (status === 'loading' || status === 'error') {
|
||
return false;
|
||
}
|
||
|
||
this.iconLoadState.set(iconUrl, 'loading');
|
||
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();
|
||
};
|
||
image.onerror = () => {
|
||
this.iconLoadState.set(iconUrl, 'error');
|
||
this.requestRefreshPointLayerStyles();
|
||
};
|
||
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;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private requestRefreshPointLayerStyles() {
|
||
if (this.pointLayerStyleRefreshFrameId !== null) {
|
||
return;
|
||
}
|
||
|
||
this.pointLayerStyleRefreshFrameId = window.requestAnimationFrame(() => {
|
||
this.pointLayerStyleRefreshFrameId = null;
|
||
this.refreshPointLayerStyles();
|
||
});
|
||
}
|
||
|
||
private refreshPointLayerStyles() {
|
||
this.pointLayerManager.forEachLayer(layer => {
|
||
layer.changed();
|
||
});
|
||
this.requestRefreshPointLabelVisibility();
|
||
if (this.isBatchPopupMode) {
|
||
this.requestUpdateBatchPopups();
|
||
}
|
||
}
|
||
|
||
private requestUpdateBatchPopups(immediate = false, rebuild = true) {
|
||
if (!this.isBatchPopupMode) {
|
||
return;
|
||
}
|
||
this.batchPopupPendingRebuild = this.batchPopupPendingRebuild || rebuild;
|
||
|
||
if (immediate) {
|
||
if (this.batchPopupRefreshFrameId !== null) {
|
||
cancelAnimationFrame(this.batchPopupRefreshFrameId);
|
||
this.batchPopupRefreshFrameId = null;
|
||
}
|
||
this.updateBatchPopups();
|
||
this.batchPopupPendingRebuild = false;
|
||
return;
|
||
}
|
||
|
||
if (this.batchPopupRefreshFrameId !== null) {
|
||
return;
|
||
}
|
||
|
||
this.batchPopupRefreshFrameId = window.requestAnimationFrame(() => {
|
||
this.batchPopupRefreshFrameId = null;
|
||
if (!this.isBatchPopupMode) {
|
||
return;
|
||
}
|
||
this.updateBatchPopups();
|
||
this.batchPopupPendingRebuild = false;
|
||
});
|
||
}
|
||
|
||
/** 复用离屏 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 }
|
||
) {
|
||
const padding = 4;
|
||
return !(
|
||
left.right + padding < right.left ||
|
||
left.left - padding > right.right ||
|
||
left.bottom + padding < right.top ||
|
||
left.top - padding > right.bottom
|
||
);
|
||
}
|
||
|
||
private checkLabelCollisionWithIcon(
|
||
iconRect: { left: number; right: number; top: number; bottom: number },
|
||
labelRect: { left: number; right: number; top: number; bottom: number }
|
||
) {
|
||
// 标签避让图标时仅保留图标中心更小的保护区,并允许边缘接触不算碰撞,
|
||
// 避免像“三峡/黄龙滩”这类仅在边界轻微擦到时把文字过度压掉。
|
||
const inset = 4;
|
||
const padding = 0;
|
||
const narrowedIconRect = {
|
||
left: iconRect.left + inset,
|
||
right: iconRect.right - inset,
|
||
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 <= extendedLabelRect.left ||
|
||
narrowedIconRect.left - padding >= extendedLabelRect.right ||
|
||
narrowedIconRect.bottom + padding <= extendedLabelRect.top ||
|
||
narrowedIconRect.top - padding >= extendedLabelRect.bottom
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 点位标注最多显示两行,每行 12 个字符;超出部分在第二行末尾显示省略号。
|
||
*/
|
||
private formatPointLabelText(text: string): string {
|
||
const normalizedText = String(text || '')
|
||
.replace(/[()()]/g, '')
|
||
.trim();
|
||
if (!normalizedText) {
|
||
return '';
|
||
}
|
||
|
||
const maxLineLength = 12;
|
||
if (normalizedText.length <= maxLineLength) {
|
||
return normalizedText;
|
||
}
|
||
|
||
const firstLine = normalizedText.slice(0, maxLineLength);
|
||
if (normalizedText.length <= maxLineLength * 2) {
|
||
return `${firstLine}\n${normalizedText.slice(maxLineLength)}`;
|
||
}
|
||
|
||
const secondLine = `${normalizedText.slice(
|
||
maxLineLength,
|
||
maxLineLength + 9
|
||
)}...`;
|
||
return `${firstLine}\n${secondLine}`;
|
||
}
|
||
|
||
/**
|
||
* 根据缩放级别计算锚点图标缩放比例,统一复用,减少样式函数中的重复判断。
|
||
*/
|
||
private getDynamicPointScale(currentZoom: number): number {
|
||
let dynamicScale = 0.7 + (currentZoom - 4.5) * 0.08;
|
||
dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale));
|
||
return dynamicScale;
|
||
}
|
||
|
||
/**
|
||
* 根据图标缩放比例统一计算文字字号,避免样式函数中散落重复计算。
|
||
*/
|
||
private getPointFontSize(dynamicScale: number): number {
|
||
return Math.max(10, Math.min(24, 12 * dynamicScale));
|
||
}
|
||
|
||
/**
|
||
* 统一计算标签渲染时的纵向偏移,保持视觉位置与原效果一致。
|
||
*/
|
||
private getPointLabelRenderOffsetY(
|
||
dynamicScale: number,
|
||
labelLineCount: number
|
||
): number {
|
||
return labelLineCount > 1 ? -30 * dynamicScale : -22 * dynamicScale;
|
||
}
|
||
|
||
/**
|
||
* distance 字段表示点位密度分档,不是物理距离。
|
||
* 这里仅用它决定点位从哪个缩放级别开始参与显示候选。
|
||
*/
|
||
private getFeatureDensityValue(feature: Feature): number | null {
|
||
const rawDistance = feature.get('distance');
|
||
if (
|
||
rawDistance === undefined ||
|
||
rawDistance === null ||
|
||
rawDistance === ''
|
||
) {
|
||
return null;
|
||
}
|
||
const densityValue = Number(rawDistance);
|
||
return Number.isFinite(densityValue) ? densityValue : null;
|
||
}
|
||
|
||
private getFeatureDensityPriority(feature: Feature): number {
|
||
const densityValue = this.getFeatureDensityValue(feature);
|
||
const densityDisplayRules = getNearbyPointDensityDisplayRules();
|
||
if (densityValue === null) {
|
||
return densityDisplayRules.length;
|
||
}
|
||
|
||
const matchedRuleIndex = densityDisplayRules.findIndex(rule => {
|
||
return densityValue >= rule.minDensityValue;
|
||
});
|
||
return matchedRuleIndex >= 0
|
||
? matchedRuleIndex
|
||
: densityDisplayRules.length;
|
||
}
|
||
|
||
private getFeatureDensityMinZoom(feature: Feature): number {
|
||
const densityValue = this.getFeatureDensityValue(feature);
|
||
if (densityValue === null) {
|
||
return 0;
|
||
}
|
||
|
||
const densityDisplayRules = getNearbyPointDensityDisplayRules();
|
||
const matchedRule = densityDisplayRules.find(rule => {
|
||
return densityValue >= rule.minDensityValue;
|
||
});
|
||
if (matchedRule) {
|
||
return matchedRule.minZoom;
|
||
}
|
||
|
||
return densityDisplayRules.at(-1)?.minZoom ?? 0;
|
||
}
|
||
|
||
private isFeatureEng(feature: Feature): boolean {
|
||
const layerKey = String(feature.get('_layerKey') || '').toLowerCase();
|
||
const sttpMap = String(
|
||
feature.get('_sttpMap') || feature.get('sttpMap') || ''
|
||
).toUpperCase();
|
||
const sttpCode = String(
|
||
feature.get('sttpCode') || feature.get('sttp') || ''
|
||
).toUpperCase();
|
||
|
||
return (
|
||
layerKey.includes('eng_point') ||
|
||
sttpMap === 'ENG' ||
|
||
sttpMap === 'ENG2' ||
|
||
sttpCode === 'ENG'
|
||
);
|
||
}
|
||
|
||
private isFeatureEngAlarm(feature: Feature): boolean {
|
||
const layerKey = String(
|
||
feature.get('_layerKey') || feature.get('type') || ''
|
||
).toLowerCase();
|
||
const legendState = String(feature.get('anchoPointState') || '')
|
||
.trim()
|
||
.toLowerCase();
|
||
|
||
return (
|
||
layerKey.includes('eng_alarm_point') ||
|
||
layerKey.includes('alarm_range') ||
|
||
legendState.startsWith('alarm_range_') ||
|
||
legendState.startsWith('large_eng_built_alarm_range_') ||
|
||
legendState.startsWith('mid_eng_built_alarm_range_')
|
||
);
|
||
}
|
||
|
||
private getFeatureEngRenderPriority(feature: Feature): number {
|
||
if (this.isFeatureEngAlarm(feature)) {
|
||
return 300;
|
||
}
|
||
return this.isFeatureEng(feature) ? 200 : 100;
|
||
}
|
||
|
||
/**
|
||
* 如果点位在当前视图中没有近邻点,则允许跳过静态密度门槛直接参与显示。
|
||
* 这样像“戈兰滩”这类 distance 档位偏密、但当前周围实际很空的点也能出现。
|
||
*/
|
||
private shouldBypassDensityGateForIsolatedFeature(feature: Feature): boolean {
|
||
if (!this.map) {
|
||
return false;
|
||
}
|
||
|
||
const layerKey = String(feature.get('_layerKey') || '');
|
||
const geometry = feature.getGeometry();
|
||
if (!geometry || geometry.getType() !== 'Point') {
|
||
return false;
|
||
}
|
||
|
||
const featurePixel = this.map.getPixelFromCoordinate(
|
||
(geometry as Point).getCoordinates()
|
||
);
|
||
if (!featurePixel) {
|
||
return false;
|
||
}
|
||
|
||
const nearbyPixelThreshold = 56;
|
||
const viewportFeatures = this.pointLayerManager.getFeaturesInViewport();
|
||
|
||
return !viewportFeatures.some(otherFeature => {
|
||
if (otherFeature === feature) {
|
||
return false;
|
||
}
|
||
if (otherFeature.get('_legendVisible') === false) {
|
||
return false;
|
||
}
|
||
if (otherFeature.get('_regionVisible') === false) {
|
||
return false;
|
||
}
|
||
if (String(otherFeature.get('_layerKey') || '') !== layerKey) {
|
||
return false;
|
||
}
|
||
|
||
const otherGeometry = otherFeature.getGeometry();
|
||
if (!otherGeometry || otherGeometry.getType() !== 'Point') {
|
||
return false;
|
||
}
|
||
|
||
const otherPixel = this.map?.getPixelFromCoordinate(
|
||
(otherGeometry as Point).getCoordinates()
|
||
);
|
||
if (!otherPixel) {
|
||
return false;
|
||
}
|
||
|
||
return (
|
||
Math.hypot(
|
||
otherPixel[0] - featurePixel[0],
|
||
otherPixel[1] - featurePixel[1]
|
||
) <= nearbyPixelThreshold
|
||
);
|
||
});
|
||
}
|
||
|
||
private shouldRenderFeatureByDensity(
|
||
feature: Feature,
|
||
currentZoom: number
|
||
): boolean {
|
||
const minZoom = this.getFeatureDensityMinZoom(feature);
|
||
const bypassDensityGate =
|
||
currentZoom < minZoom &&
|
||
this.shouldBypassDensityGateForIsolatedFeature(feature);
|
||
return currentZoom >= minZoom || bypassDensityGate;
|
||
}
|
||
|
||
/**
|
||
* 近邻点统一参与原位堆叠,具体谁显示在顶层交给碰撞排序和图层优先级控制。
|
||
*/
|
||
private shouldRenderNearbyFeature(): boolean {
|
||
return true;
|
||
}
|
||
/**
|
||
* 初始化加载基础图层
|
||
* @param layer 图层配置对象
|
||
*/
|
||
addBaseDataLayer(layer: any, isShow: boolean, isCache = false): void {
|
||
if (!this.map) return;
|
||
|
||
// 备注:同 key 的底图重复添加前先移除旧实例,避免注册表覆盖后旧图层仍残留在地图上。
|
||
const existingLayer = layer?.key
|
||
? this.layerRegistry.get(layer.key as string)
|
||
: null;
|
||
if (existingLayer) {
|
||
this.map.removeLayer(existingLayer);
|
||
this.layerRegistry.delete(layer.key);
|
||
}
|
||
|
||
if (layer.type === 'wmts') {
|
||
if (!layer.url) return;
|
||
const url = !isCache ? layer.url : layer.url_3d;
|
||
const urlParams = new URLSearchParams(url.split('?')[1]);
|
||
if (layer.key === this.REGISTRY_KEY) {
|
||
this.baseLayerConfig = layer;
|
||
}
|
||
const layerName: string = urlParams.get('LAYER') || '';
|
||
const matrixSetName: any = urlParams.get('TILEMATRIXSET');
|
||
|
||
const projection = getProjection('EPSG:3857');
|
||
if (!projection) {
|
||
console.error('无法获取 EPSG:3857 投影');
|
||
return;
|
||
}
|
||
const projectionExtent = projection.getExtent();
|
||
|
||
const size = getWidth(projectionExtent) / 256;
|
||
const maxZoom = 13;
|
||
|
||
const resolutions = new Array(maxZoom + 1);
|
||
const matrixIds = new Array(maxZoom + 1);
|
||
|
||
for (let z = 0; z <= maxZoom; ++z) {
|
||
resolutions[z] = size / Math.pow(2, z);
|
||
matrixIds[z] = `${matrixSetName}:${z}`;
|
||
}
|
||
|
||
const wmtsLayer = new TileLayer({
|
||
source: new WMTS({
|
||
url: layer.url.split('?')[0],
|
||
layer: layerName,
|
||
matrixSet: matrixSetName,
|
||
format: 'image/png',
|
||
projection: projection,
|
||
tileGrid: new WMTSTileGrid({
|
||
origin: getTopLeft(projectionExtent),
|
||
resolutions: resolutions,
|
||
matrixIds: matrixIds
|
||
}),
|
||
style: 'default',
|
||
wrapX: true,
|
||
crossOrigin: 'anonymous'
|
||
})
|
||
});
|
||
|
||
if (layer.key === this.REGISTRY_KEY) {
|
||
!isCache ? wmtsLayer.setZIndex(-100) : wmtsLayer.setZIndex(-99);
|
||
} else {
|
||
wmtsLayer.setZIndex(-100);
|
||
}
|
||
wmtsLayer.set('key', layer.key);
|
||
wmtsLayer.type = 'layer';
|
||
wmtsLayer.setVisible(isShow);
|
||
this.layerRegistry.set(layer.key, wmtsLayer);
|
||
this.map.addLayer(wmtsLayer);
|
||
layer._layer = wmtsLayer;
|
||
} else if (layer.type === 'wfs') {
|
||
if (!this.view) return;
|
||
// 先加载数据,在原始 GeoJSON 层面对坐标做 4326 有效性筛选,删除混入的异常投影数据
|
||
const isCoordIn4326Range = (lon: number, lat: number) =>
|
||
lon >= -180 && lon <= 180 && lat >= -90 && lat <= 90;
|
||
const isValidGeoJsonFeature = (feature: any): boolean => {
|
||
const geom = feature?.geometry;
|
||
if (!geom) return false;
|
||
const flat = geom.coordinates.flat(Infinity) as number[];
|
||
for (let i = 0; i < flat.length; i += 2) {
|
||
if (!isCoordIn4326Range(flat[i], flat[i + 1])) return false;
|
||
}
|
||
return true;
|
||
};
|
||
const vectorSource = new VectorSource();
|
||
// 先加载数据,过滤异常坐标,再创建图层添加到地图
|
||
fetch(layer.url)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
data.features = (data.features || []).filter(isValidGeoJsonFeature);
|
||
const features = new GeoJSON().readFeatures(data, {
|
||
// dataProjection: 'EPSG:4326',
|
||
featureProjection: 'EPSG:3857' // 确保转换到地图使用的投影
|
||
});
|
||
vectorSource.addFeatures(features);
|
||
})
|
||
.catch(err => console.error(`WFS 图层加载失败 [${layer.key}]:`, err));
|
||
const wfsLayer = new VectorLayer({
|
||
source: vectorSource,
|
||
style: new Style({
|
||
stroke: new Stroke({
|
||
color: '#038303',
|
||
width: 3
|
||
})
|
||
}),
|
||
zIndex: -98,
|
||
visible: layer.visible !== false
|
||
});
|
||
wfsLayer.set('bjColor', layer.color);
|
||
wfsLayer.set('key', layer.key);
|
||
wfsLayer.setVisible(isShow);
|
||
this.layerRegistry.set(layer.key, wfsLayer);
|
||
this.map.addLayer(wfsLayer);
|
||
layer._layer = wfsLayer;
|
||
} else if (layer.type == 'raster-dem') {
|
||
const tileLayer = new TileLayer({
|
||
source: new XYZ({
|
||
url: layer.url,
|
||
wrapX: false,
|
||
crossOrigin: 'anonymous'
|
||
})
|
||
});
|
||
|
||
tileLayer.set('key', layer.key);
|
||
tileLayer.setZIndex(-100);
|
||
tileLayer.setVisible(isShow);
|
||
tileLayer.type = 'layer';
|
||
this.layerRegistry.set(layer.key, tileLayer);
|
||
this.map.addLayer(tileLayer);
|
||
} else if (layer.type === 'vector') {
|
||
if (layer.key === 'hydropBase') {
|
||
this.hydropBaseConfig = layer;
|
||
}
|
||
}
|
||
}
|
||
/**
|
||
* 根据 key 设置底图图层显隐
|
||
* @param layerKey 图层 key
|
||
* @param visible 是否显示
|
||
*/
|
||
private setBaseLayerVisible(layerKey: string, visible: boolean): void {
|
||
const layer = this.layerRegistry.get(layerKey);
|
||
if (layer) {
|
||
layer.setVisible(visible);
|
||
}
|
||
}
|
||
/**
|
||
* 同步当前激活底图源的显隐,保证树勾选和下方图源切换使用同一套可见性规则
|
||
* @param checked 是否显示当前激活底图
|
||
*/
|
||
private syncActiveBaseLayerVisibility(checked: boolean): void {
|
||
this.setBaseLayerVisible(this.REGISTRY_KEY, checked);
|
||
|
||
this.rasterBaseLayerKeys.forEach(key => {
|
||
const shouldShow =
|
||
checked &&
|
||
this.activeBaseLayerKey !== 's_province_boundaries' &&
|
||
key === this.activeBaseLayerKey;
|
||
this.setBaseLayerVisible(key, shouldShow);
|
||
});
|
||
}
|
||
/**
|
||
* 获取当前应应用基地裁切的底图图层集合
|
||
* @returns 当前可见底图图层列表
|
||
*/
|
||
private getCurrentMaskTargetLayers(): TileLayer[] {
|
||
const layers: TileLayer[] = [];
|
||
const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY);
|
||
|
||
if (baseLayer instanceof TileLayer) {
|
||
layers.push(baseLayer);
|
||
}
|
||
|
||
if (this.activeBaseLayerKey !== 's_province_boundaries') {
|
||
const activeRasterLayer = this.layerRegistry.get(this.activeBaseLayerKey);
|
||
if (
|
||
activeRasterLayer instanceof TileLayer &&
|
||
activeRasterLayer !== baseLayer
|
||
) {
|
||
layers.push(activeRasterLayer);
|
||
}
|
||
}
|
||
|
||
return layers;
|
||
}
|
||
/**
|
||
* 重新把当前基地裁切绑定到最新底图实例,保证切换图源后边界裁切持续生效
|
||
*/
|
||
private reapplyCurrentBaseMask(): void {
|
||
if (!this.currentClipGeoJson) {
|
||
return;
|
||
}
|
||
|
||
const targetLayers = this.getCurrentMaskTargetLayers();
|
||
if (targetLayers.length === 0) {
|
||
return;
|
||
}
|
||
|
||
this.regionMaskManager.applyMapMaskToLayers(
|
||
targetLayers,
|
||
this.currentClipGeoJson
|
||
);
|
||
}
|
||
enableNortheastMask(): void {
|
||
const baseLayer = this.layerRegistry.get(this.REGISTRY_KEY);
|
||
|
||
if (!this.geoJsonData1) {
|
||
console.warn('东北边界数据尚未加载');
|
||
return;
|
||
}
|
||
|
||
if (baseLayer && baseLayer instanceof TileLayer) {
|
||
this.regionMaskManager.applyMapMask(baseLayer, this.geoJsonData1);
|
||
} else {
|
||
console.warn('未找到全量底图图层或图层类型错误');
|
||
}
|
||
}
|
||
/**
|
||
* 控制特定区域图层的显示(互斥显示)
|
||
* @param regionId 区域ID (例如 "hebei", "13", "01" 等,需与图层 Key 或属性对应)
|
||
* @param isAll 是否显示所有 (true: 显示所有图层; false: 仅显示匹配 regionId 的图层,隐藏其他)
|
||
*/
|
||
async jdPanelControlShowAndHidden(
|
||
_regionId: string,
|
||
isAll: boolean
|
||
): Promise<void> {
|
||
if (!this.map || !this.hydropBaseConfig) {
|
||
console.warn('地图未初始化或 hydropBaseConfig 未配置');
|
||
return;
|
||
}
|
||
|
||
if (this.clipRequestController) {
|
||
this.clipRequestController.abort();
|
||
this.clipRequestController = null;
|
||
}
|
||
|
||
this.BASEID = _regionId;
|
||
|
||
if (!isAll) {
|
||
this.currentClipGeoJson = null;
|
||
this.regionMaskManager.clearMapMask();
|
||
this.regionMaskManager.showAllPoints();
|
||
return;
|
||
}
|
||
|
||
const baseLayers = this.getCurrentMaskTargetLayers();
|
||
if (baseLayers.length === 0) {
|
||
console.warn('未找到底图图层,无法应用遮罩');
|
||
return;
|
||
}
|
||
|
||
this.regionMaskManager.hideAllPoints();
|
||
this.clipRequestController = new AbortController();
|
||
const signal = this.clipRequestController.signal;
|
||
|
||
try {
|
||
const url =
|
||
this.hydropBaseConfig.geojson_url +
|
||
`&cql_filter=BASEID='${this.BASEID}'`;
|
||
|
||
const geoJsonData = await this.loadGeoJsonData(url, signal);
|
||
|
||
if (signal.aborted) {
|
||
return;
|
||
}
|
||
|
||
if (this.BASEID !== _regionId) {
|
||
return;
|
||
}
|
||
|
||
this.currentClipGeoJson = geoJsonData;
|
||
this.regionMaskManager.filterPointsByRegion(geoJsonData);
|
||
this.regionMaskManager.fitViewToGeoJson(geoJsonData);
|
||
this.regionMaskManager.applyMapMaskToLayers(baseLayers, geoJsonData);
|
||
} catch (error: any) {
|
||
if (error.name === 'AbortError') {
|
||
return;
|
||
}
|
||
this.currentClipGeoJson = null;
|
||
console.error('加载裁切数据失败:', error);
|
||
this.regionMaskManager.clearMapMask();
|
||
this.regionMaskManager.showAllPoints();
|
||
} finally {
|
||
if (this.clipRequestController?.signal === signal) {
|
||
this.clipRequestController = null;
|
||
}
|
||
}
|
||
}
|
||
/**
|
||
* 基础图层显示影隐藏方法
|
||
* @param layerType 图层类型 (备用,优先使用 key)
|
||
* @param key 图层唯一标识 Key
|
||
* @param checked true 为显示,false 为隐藏
|
||
*/
|
||
controlBaseLayerTreeShowAndHidden(
|
||
layerType: string,
|
||
key: string,
|
||
checked: boolean
|
||
): void {
|
||
if (!this.map) return;
|
||
const registryKey = key || layerType;
|
||
|
||
if (!registryKey) {
|
||
console.warn(
|
||
'controlBaseLayerTreeShowAndHidden: 缺少有效的 Key 或 LayerType'
|
||
);
|
||
return;
|
||
}
|
||
if (registryKey === this.REGISTRY_KEY) {
|
||
this.syncActiveBaseLayerVisibility(checked);
|
||
return;
|
||
}
|
||
const layerInstance = this.layerRegistry.get(registryKey as string);
|
||
if (layerInstance) {
|
||
layerInstance.setVisible(checked);
|
||
// 图层显隐变化时刷新 batch popup
|
||
if (this.isBatchPopupMode) {
|
||
this.requestUpdateBatchPopups(true);
|
||
}
|
||
} else {
|
||
console.warn(
|
||
`未找到标识为 [${registryKey}] 的图层实例,当前注册表 keys:`,
|
||
Array.from(this.layerRegistry.keys())
|
||
);
|
||
}
|
||
}
|
||
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {
|
||
this.pointLayerManager.setLayerVisible(layerType, checked);
|
||
this.requestRefreshPointLabelVisibility(true);
|
||
// 图层显隐变化时刷新 batch popup
|
||
if (this.isBatchPopupMode) {
|
||
this.requestUpdateBatchPopups(true);
|
||
}
|
||
}
|
||
|
||
hasLayer(layerType: string): boolean {
|
||
return this.pointLayerManager.hasLayer(layerType as string);
|
||
}
|
||
|
||
hasBaseLayer(layerKey: string): boolean {
|
||
return this.layerRegistry.has(layerKey);
|
||
}
|
||
|
||
/**
|
||
* 缩放地图
|
||
* @param type 'out' 缩小, 'in' 放大
|
||
*/
|
||
zoomToggle(type: 'out' | 'in'): void {
|
||
if (!this.map || !this.view) return;
|
||
|
||
const currentZoom = this.view.getZoom();
|
||
if (currentZoom === undefined) return;
|
||
|
||
// 定义缩放步长,对应 Leaflet 的 zoomDelta
|
||
const zoomDelta = 1;
|
||
|
||
let targetZoom = currentZoom;
|
||
|
||
if (type === 'in') {
|
||
targetZoom = currentZoom + zoomDelta;
|
||
// 限制最大缩放级别
|
||
if (targetZoom > MAX_ZOOM) {
|
||
targetZoom = MAX_ZOOM;
|
||
}
|
||
} else {
|
||
targetZoom = currentZoom - zoomDelta;
|
||
// 限制最小缩放级别
|
||
if (targetZoom < MIN_ZOOM) {
|
||
targetZoom = MIN_ZOOM;
|
||
}
|
||
}
|
||
|
||
// 使用 animate 实现平滑缩放动画
|
||
this.view.animate({
|
||
zoom: targetZoom,
|
||
duration: 250, // 动画持续时间 (ms),与滚轮缩放保持一致
|
||
easing: t => t // 线性缓动,也可以引入 ol/easing 使用更复杂的曲线
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 切换底图
|
||
* @param key 业务标识 Key (如 's_province_boundaries', 'BASEMAP-white', 'BASEMAP-img')
|
||
*/
|
||
baseLayerSwitcher(key: string, checked: boolean): void {
|
||
if (!this.map || !this.view) return;
|
||
this.activeBaseLayerKey = key;
|
||
const oldLayer = this.layerRegistry.get(this.REGISTRY_KEY);
|
||
if (oldLayer) {
|
||
this.map.removeLayer(oldLayer);
|
||
this.layerRegistry.delete(this.REGISTRY_KEY);
|
||
}
|
||
|
||
if (key == 's_province_boundaries') {
|
||
this.addBaseDataLayer(this.baseLayerConfig, checked);
|
||
this.hideBaseMapLayers(this.rasterBaseLayerKeys);
|
||
} else {
|
||
this.addBaseDataLayer(this.baseLayerConfig, checked, true);
|
||
this.rasterBaseLayerKeys.forEach(layerKey => {
|
||
if (layerKey === key) {
|
||
this.addBaseDataLayer(servers.BaseLayer[layerKey], checked);
|
||
} else {
|
||
this.setBaseLayerVisible(layerKey, false);
|
||
}
|
||
});
|
||
}
|
||
|
||
this.reapplyCurrentBaseMask();
|
||
}
|
||
/**
|
||
* 隐藏指定的底图图层
|
||
* @param layerKeys 要隐藏的图层 key 数组
|
||
*/
|
||
hideBaseMapLayers(layerKeys: string[]): void {
|
||
layerKeys.forEach(key => {
|
||
const layer = this.layerRegistry.get(key);
|
||
if (layer) {
|
||
layer.setVisible(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 显示指定的底图图层
|
||
* @param layerKeys 要显示的图层 key 数组
|
||
*/
|
||
showBaseMapLayers(layerKeys: string[]): void {
|
||
layerKeys.forEach(key => {
|
||
const layer = this.layerRegistry.get(key);
|
||
if (layer) {
|
||
layer.setVisible(true);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 添加梯级流域图 (Vector Layer) - 使用本地 GeoJSON 数据
|
||
* @param layer 图层配置对象
|
||
* @param fillcolor 填充颜色
|
||
* @param outlineColor 边框颜色
|
||
* @param datas 需要显示的 RVCD 列表 (例如: ['SJLY25', ...]),如果为空则显示所有或根据业务逻辑处理
|
||
*/
|
||
addTertiarybasinLayer(
|
||
layer: any,
|
||
fillcolor: any,
|
||
outlineColor: any,
|
||
datas: any
|
||
): void {
|
||
if (!this.map || !this.view) return;
|
||
|
||
// 1. 检查是否已存在该图层,已存在时直接切换为显示并刷新样式
|
||
const existingLayer = this.layerRegistry.get(layer.key);
|
||
|
||
// 2. 创建样式函数
|
||
const visibleStyle = new Style({
|
||
fill: new Fill({ color: fillcolor }),
|
||
stroke: new Stroke({ color: outlineColor, width: 1 })
|
||
});
|
||
|
||
const hiddenStyle = new Style({
|
||
fill: new Fill({ color: 'rgba(0,0,0,0)' }),
|
||
stroke: new Stroke({ color: 'rgba(0,0,0,0)' })
|
||
});
|
||
|
||
if (existingLayer) {
|
||
const allowedIds = Array.isArray(datas) ? datas : [];
|
||
const existingFeatures =
|
||
existingLayer.getSource?.()?.getFeatures?.() ?? [];
|
||
|
||
existingFeatures.forEach(feature => {
|
||
const rvcd = feature.get('RVCD');
|
||
const isVisible =
|
||
allowedIds.length > 0 ? rvcd && allowedIds.includes(rvcd) : true;
|
||
|
||
feature.setStyle(isVisible ? visibleStyle : hiddenStyle);
|
||
});
|
||
|
||
existingLayer.setVisible(layer.visible !== false);
|
||
return;
|
||
}
|
||
|
||
// 3. 创建矢量源 (VectorSource),并通过 layer.geojson_url 加载数据
|
||
const vectorSource = new VectorSource();
|
||
|
||
if (layer?.geojson_url) {
|
||
this.loadGeoJsonData(VITE_APP_MAP_URL + layer.geojson_url).then(
|
||
geoJsonData => {
|
||
if (!geoJsonData) return;
|
||
|
||
const features = new GeoJSON().readFeatures(geoJsonData, {
|
||
featureProjection: 'EPSG:3857' // 确保转换到地图使用的投影
|
||
});
|
||
|
||
vectorSource.addFeatures(features);
|
||
|
||
// 4. 数据过滤逻辑
|
||
const allowedIds = Array.isArray(datas) ? datas : [];
|
||
|
||
features.forEach(feature => {
|
||
// 获取属性中的 RVCD
|
||
const rvcd = feature.get('RVCD');
|
||
|
||
let isVisible = false;
|
||
|
||
// 逻辑:如果 datas 为空,通常意味着显示所有(或者都不显示,视具体需求而定)
|
||
// 这里假设:datas 有值时严格过滤;datas 为空时显示所有
|
||
if (allowedIds.length > 0) {
|
||
isVisible = rvcd && allowedIds.includes(rvcd);
|
||
} else {
|
||
isVisible = true;
|
||
}
|
||
|
||
// 应用样式
|
||
feature.setStyle(isVisible ? visibleStyle : hiddenStyle);
|
||
});
|
||
}
|
||
);
|
||
}
|
||
|
||
// 5. 创建矢量图层 (VectorLayer)
|
||
const vectorLayer = new VectorLayer({
|
||
source: vectorSource,
|
||
style: null, // 样式已应用在 Feature 上,这里设为 null 或保留默认
|
||
zIndex: 100,
|
||
visible: layer.visible !== false
|
||
});
|
||
|
||
// 6. 注册并添加到地图
|
||
this.layerRegistry.set(layer.key, vectorLayer);
|
||
this.map.addLayer(vectorLayer);
|
||
}
|
||
/**
|
||
* 隐藏梯级流域图
|
||
* @param layer 图层配置对象 (需包含 key 属性)
|
||
*/
|
||
hideTertiarybasinLayer(layer: any): void {
|
||
if (!this.map || !layer || !layer.key) {
|
||
console.warn('hideTertiarybasinLayer: 无效的图层或地图未初始化');
|
||
return;
|
||
}
|
||
// 1. 从注册表中获取图层实例
|
||
const layerInstance = this.layerRegistry.get(layer.key);
|
||
|
||
if (layerInstance) {
|
||
layerInstance.setVisible(false);
|
||
} else {
|
||
console.warn(`未找到标识为 [${layer.key}] 的梯级流域图层实例`);
|
||
}
|
||
}
|
||
/**
|
||
* 删除描点图层
|
||
* @param layerKey 图层 key
|
||
*/
|
||
removePointLayer(layerKey: string): void {
|
||
this.pointLayerManager.removeLayer(layerKey);
|
||
}
|
||
/**
|
||
* 地图打印/导出
|
||
* 将当前地图视图导出为 PNG 图片,背景强制为白色
|
||
*/
|
||
mapOutPut(): void {
|
||
if (!this.map) {
|
||
console.warn('地图未初始化,无法打印');
|
||
return;
|
||
}
|
||
const mapElement = this.map.getTargetElement();
|
||
const canvas = mapElement.querySelector('canvas');
|
||
|
||
if (!canvas) {
|
||
console.error('未找到地图 Canvas 元素,无法导出');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const width = canvas.width;
|
||
const height = canvas.height;
|
||
|
||
const offscreenCanvas = document.createElement('canvas');
|
||
offscreenCanvas.width = width;
|
||
offscreenCanvas.height = height;
|
||
const ctx = offscreenCanvas.getContext('2d');
|
||
|
||
if (!ctx) {
|
||
throw new Error('无法获取 Canvas 上下文');
|
||
}
|
||
|
||
ctx.fillStyle = '#FFFFFF';
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
ctx.drawImage(canvas, 0, 0);
|
||
|
||
const dataURL = offscreenCanvas.toDataURL('image/png');
|
||
|
||
const link = document.createElement('a');
|
||
link.download = `map_export_${new Date().getTime()}.png`;
|
||
link.href = dataURL;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
} catch (e) {
|
||
console.error('地图导出失败', e);
|
||
alert('地图导出失败,请检查控制台错误信息。');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 切换双击缩放交互的状态
|
||
*/
|
||
private toggleDoubleClickZoom(active: boolean): void {
|
||
if (!this.map) return;
|
||
this.map.getInteractions().forEach(interaction => {
|
||
if (interaction instanceof DoubleClickZoom) {
|
||
interaction.setActive(active);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 创建并返回测量 Tooltip Overlay
|
||
*/
|
||
private createMeasureTooltipOverlay(): Overlay {
|
||
const element = document.createElement('div');
|
||
element.className = 'measure-tooltip tooltip-measure';
|
||
Object.assign(element.style, {
|
||
background: 'rgba(0, 0, 0, 0.7)',
|
||
color: 'white',
|
||
padding: '4px 8px',
|
||
borderRadius: '4px',
|
||
fontSize: '12px',
|
||
whiteSpace: 'nowrap'
|
||
});
|
||
|
||
return new Overlay({
|
||
element,
|
||
offset: [0, -15],
|
||
positioning: 'bottom-center'
|
||
});
|
||
}
|
||
/**
|
||
* 通用测量启动方法
|
||
* @param geomType 'LineString' | 'Polygon'
|
||
* @param drawStyle 绘制中的样式函数
|
||
* @param finishedStyle 完成后的样式
|
||
* @param onResult 计算结果的回调 (feature, resultText)
|
||
*/
|
||
private startMeasurement(
|
||
geomType: 'LineString' | 'Polygon',
|
||
drawStyle: any,
|
||
finishedStyle: Style,
|
||
onResult: (feature: Feature, text: string) => void
|
||
): void {
|
||
if (!this.map || !this.view) return;
|
||
|
||
// 1. 初始化图层
|
||
if (!this.measureLayer) {
|
||
this.measureSource = new VectorSource();
|
||
this.measureLayer = new VectorLayer({
|
||
source: this.measureSource,
|
||
zIndex: 1000
|
||
});
|
||
this.map.addLayer(this.measureLayer);
|
||
}
|
||
|
||
// 2. 清理旧交互
|
||
if (this.drawInteraction) {
|
||
this.map.removeInteraction(this.drawInteraction);
|
||
}
|
||
|
||
// 禁用双击缩放
|
||
this.toggleDoubleClickZoom(false);
|
||
|
||
// 3. 创建绘制交互
|
||
this.drawInteraction = new Draw({
|
||
source: this.measureSource!,
|
||
type: geomType,
|
||
style: drawStyle
|
||
});
|
||
|
||
this.map.addInteraction(this.drawInteraction);
|
||
|
||
let changeListenerKey: any;
|
||
let currentTooltip: Overlay | null = null;
|
||
|
||
// 4. 监听绘制开始
|
||
this.drawInteraction.on('drawstart', (evt: any) => {
|
||
const feature = evt.feature;
|
||
const geom = feature.getGeometry();
|
||
|
||
// 创建动态 Tooltip
|
||
currentTooltip = this.createMeasureTooltipOverlay();
|
||
this.map?.addOverlay(currentTooltip);
|
||
|
||
// 监听几何变化更新 Tooltip
|
||
changeListenerKey = geom!.on('change', () => {
|
||
if (currentTooltip && currentTooltip.getElement() && geom) {
|
||
// 根据类型计算临时值
|
||
let tempText = '';
|
||
if (geomType === 'LineString') {
|
||
tempText = this.formatLength(geom as LineString);
|
||
} else {
|
||
tempText = this.formatArea(
|
||
geom as import('ol/geom/Polygon').default
|
||
);
|
||
}
|
||
|
||
currentTooltip.getElement()!.innerHTML = tempText;
|
||
|
||
// 对于多边形,Tooltip 跟随鼠标最后一个点;对于线,也是最后一个点
|
||
const lastCoord = (geom as any).getLastCoordinate();
|
||
if (lastCoord) currentTooltip.setPosition(lastCoord);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 5. 监听绘制结束
|
||
this.drawInteraction.on('drawend', (evt: any) => {
|
||
const feature = evt.feature;
|
||
const geom = feature.getGeometry();
|
||
|
||
// ✅ 修复:检查几何体是否有效
|
||
// 对于 Polygon,如果坐标点数不足(例如只点了两下),geom 可能是无效的或面积为0
|
||
if (!geom || geom.getCoordinates().length === 0) {
|
||
console.warn('无效的几何体,已忽略');
|
||
this.measureSource?.removeFeature(feature);
|
||
if (currentTooltip) this.map?.removeOverlay(currentTooltip);
|
||
if (changeListenerKey) unByKey(changeListenerKey);
|
||
return;
|
||
}
|
||
|
||
// 清理监听和临时 Tooltip
|
||
if (changeListenerKey) unByKey(changeListenerKey);
|
||
if (currentTooltip) this.map?.removeOverlay(currentTooltip);
|
||
|
||
// ✅ 修复:强制设置样式,确保即使默认样式有问题也能显示
|
||
feature.setStyle(finishedStyle);
|
||
|
||
// 计算最终结果并回调
|
||
let resultText = '';
|
||
let isValidResult = true;
|
||
|
||
if (geomType === 'LineString') {
|
||
resultText = this.formatLength(geom as LineString);
|
||
} else {
|
||
// 对于多边形,再次检查面积是否过小或无效
|
||
const polyGeom = geom as import('ol/geom/Polygon').default;
|
||
const area = getSphericalArea(polyGeom);
|
||
if (area < 0.0001) {
|
||
// 极小面积视为无效绘制
|
||
isValidResult = false;
|
||
console.warn('面积过小,已忽略');
|
||
this.measureSource?.removeFeature(feature);
|
||
} else {
|
||
resultText = this.formatArea(polyGeom);
|
||
}
|
||
}
|
||
|
||
// 只有结果有效才添加标签
|
||
if (isValidResult) {
|
||
onResult(feature, resultText);
|
||
}
|
||
|
||
// 清理交互
|
||
this.map?.removeInteraction(this.drawInteraction);
|
||
this.drawInteraction = null;
|
||
|
||
// 恢复双击缩放
|
||
setTimeout(() => {
|
||
this.toggleDoubleClickZoom(true);
|
||
}, 0);
|
||
});
|
||
}
|
||
/**
|
||
* 长度量算
|
||
*/
|
||
lengthCalculate(): void {
|
||
this.startMeasurement(
|
||
'LineString',
|
||
(feature: any) => {
|
||
// 绘制中只显示线,隐藏点
|
||
return feature.getGeometry()?.getType() === 'LineString'
|
||
? new Style({
|
||
stroke: new Stroke({
|
||
color: '#ffcc33',
|
||
lineDash: [10, 10],
|
||
width: 2
|
||
})
|
||
})
|
||
: [];
|
||
},
|
||
new Style({
|
||
stroke: new Stroke({ color: '#9AD8E7', width: 3 }) // 加粗一点
|
||
}),
|
||
(feature, text) => {
|
||
this.addFixedLabel(feature, text);
|
||
}
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 面积量算
|
||
*/
|
||
areCalculate(): void {
|
||
this.startMeasurement(
|
||
'Polygon',
|
||
(feature: any) => {
|
||
// 绘制中显示线和填充
|
||
return feature.getGeometry()?.getType() === 'Polygon'
|
||
? new Style({
|
||
stroke: new Stroke({
|
||
color: '#ffcc33',
|
||
lineDash: [10, 10],
|
||
width: 2
|
||
}),
|
||
fill: new Fill({ color: 'rgba(255, 204, 51, 0.3)' }) // 提高透明度以便看清
|
||
})
|
||
: [];
|
||
},
|
||
new Style({
|
||
stroke: new Stroke({ color: '#9AD8E7', width: 3 }), // 加粗边框
|
||
fill: new Fill({ color: 'rgba(154, 216, 231, 0.4)' }) // 提高填充可见度
|
||
}),
|
||
(feature, text) => {
|
||
this.addFixedLabel(feature, text);
|
||
}
|
||
);
|
||
}
|
||
/**
|
||
* 移除量算结果
|
||
*/
|
||
removeQueryLayer(): void {
|
||
// 1. 清除矢量数据 (线条)
|
||
if (this.measureSource) {
|
||
this.measureSource.clear();
|
||
}
|
||
|
||
// 2. 清除所有相关 Overlay (Label)
|
||
if (this.map) {
|
||
// ✅ 修复:先获取所有 Overlay 的副本,避免在遍历过程中修改原数组导致漏删
|
||
const overlaysToRemove: Overlay[] = [];
|
||
|
||
this.map.getOverlays().forEach(overlay => {
|
||
const el = overlay.getElement();
|
||
// 通过类名判断是否为我们创建的测量标签
|
||
if (
|
||
el &&
|
||
(el.classList.contains('measure-tooltip') ||
|
||
el.classList.contains('measure-label-container'))
|
||
) {
|
||
overlaysToRemove.push(overlay);
|
||
}
|
||
});
|
||
|
||
// 统一移除
|
||
overlaysToRemove.forEach(overlay => {
|
||
this.map?.removeOverlay(overlay);
|
||
});
|
||
|
||
// 确保恢复双击缩放
|
||
this.toggleDoubleClickZoom(true);
|
||
}
|
||
|
||
// 3. 清除交互
|
||
if (this.drawInteraction) {
|
||
this.map?.removeInteraction(this.drawInteraction);
|
||
this.drawInteraction = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 添加固定的 Label (带删除按钮)
|
||
*/
|
||
private addFixedLabel(feature: Feature, lengthText: string): void {
|
||
if (!this.map) return;
|
||
|
||
const container = document.createElement('div');
|
||
container.className = 'measure-label-container';
|
||
|
||
// 使用 Object.assign 批量设置样式,代码更整洁
|
||
Object.assign(container.style, {
|
||
position: 'absolute',
|
||
border: '1px solid #000',
|
||
padding: '4px 8px',
|
||
borderRadius: '4px',
|
||
fontSize: '12px',
|
||
color: '#000',
|
||
cursor: 'default',
|
||
whiteSpace: 'nowrap',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
zIndex: '1001',
|
||
pointerEvents: 'auto' // 确保可以点击
|
||
});
|
||
|
||
const textSpan = document.createElement('span');
|
||
textSpan.innerText = lengthText;
|
||
|
||
const closeBtn = document.createElement('span');
|
||
closeBtn.innerHTML = '×';
|
||
Object.assign(closeBtn.style, {
|
||
fontWeight: 'bold',
|
||
marginLeft: '5px',
|
||
cursor: 'pointer',
|
||
fontSize: '14px',
|
||
lineHeight: '1'
|
||
});
|
||
|
||
const geom: any = feature.getGeometry();
|
||
let center: any;
|
||
|
||
// ✅ 修复:根据几何类型选择标签位置
|
||
if (geom?.getType() === 'Polygon') {
|
||
// 多边形使用内部点,确保标签在面内
|
||
center = (geom as import('ol/geom/Polygon').default)
|
||
.getInteriorPoint()
|
||
.getCoordinates();
|
||
} else {
|
||
// 线使用中点
|
||
center = (geom as LineString).getCoordinateAt(0.5);
|
||
}
|
||
|
||
const overlay = new Overlay({
|
||
element: container,
|
||
positioning: 'top-center',
|
||
offset: [0, -10],
|
||
position: center
|
||
});
|
||
|
||
closeBtn.onclick = e => {
|
||
e.stopPropagation();
|
||
this.measureSource?.removeFeature(feature);
|
||
this.map?.removeOverlay(overlay);
|
||
};
|
||
|
||
container.appendChild(textSpan);
|
||
container.appendChild(closeBtn);
|
||
this.map.addOverlay(overlay);
|
||
}
|
||
/**
|
||
* 格式化长度输出 (米)
|
||
*/
|
||
private formatLength(line: LineString): string {
|
||
return '长度:' + getSphericalLength(line).toFixed(3) + 'm';
|
||
}
|
||
/**
|
||
* 格式化面积输出 (km²)
|
||
*/
|
||
private formatArea(polygon: import('ol/geom/Polygon').default): string {
|
||
// 获取球面面积 (平方米)
|
||
const areaSqMeters = getSphericalArea(polygon);
|
||
// 转换为平方公里
|
||
const areaSqKm = areaSqMeters / 1000000;
|
||
return '面积:' + areaSqKm.toFixed(3) + 'km²';
|
||
}
|
||
/**
|
||
* 图例和基地面板控制描点数据显示隐藏方法
|
||
* @param layerType 图层类型
|
||
* @param key 图层标识符(锚点状态 nameEn)
|
||
* @param baseid 基地ID
|
||
* @param checked 是否选中(显示/隐藏)
|
||
* @param isAll 是否全部操作
|
||
*/
|
||
mdLayerShowOrHidden(
|
||
layerType: string,
|
||
key?: string,
|
||
baseid: string,
|
||
checked?: boolean,
|
||
isAll?: boolean
|
||
): void {
|
||
void baseid;
|
||
void isAll;
|
||
this.pointLayerManager.setLegendVisibleByField(
|
||
layerType,
|
||
key || '',
|
||
!!checked
|
||
);
|
||
// 图例变化时刷新 batch popup
|
||
if (this.isBatchPopupMode) {
|
||
this.requestUpdateBatchPopups(true);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据 anchoPointState 控制单个图例的锚点显示隐藏
|
||
* @param layerKey 图层 key
|
||
* @param anchoPointState 锚点状态(对应图例的 nameEn)
|
||
* @param checked 是否显示
|
||
*/
|
||
setLegendPointVisible(
|
||
layerKey: string,
|
||
anchoPointState: string,
|
||
checked: boolean
|
||
): void {
|
||
this.pointLayerManager.setLegendVisibleByField(
|
||
layerKey,
|
||
anchoPointState,
|
||
checked
|
||
);
|
||
this.requestRefreshPointLabelVisibility(true);
|
||
// 图例变化时刷新 batch popup
|
||
if (this.isBatchPopupMode) {
|
||
this.requestUpdateBatchPopups(true);
|
||
}
|
||
}
|
||
|
||
switchView(_type): void {}
|
||
fitBounds(): void {}
|
||
/**
|
||
* 飞行定位到指定经纬度和缩放级别
|
||
* @param position [经度, 纬度] (EPSG:4326)
|
||
* @param zoom 缩放级别
|
||
*/
|
||
flyTopanto(position: number[], zoom: number): void {
|
||
if (!this.map || !this.view) {
|
||
console.warn('地图未初始化,无法执行飞行定位');
|
||
return;
|
||
}
|
||
|
||
// 1. 将经纬度 [lon, lat] 转换为地图投影坐标 (EPSG:3857)
|
||
const targetCenter = fromLonLat(position);
|
||
|
||
// 2. 执行动画
|
||
// duration: 动画持续时间,单位毫秒,例如 1000ms (1秒)
|
||
this.view.animate(
|
||
{
|
||
center: targetCenter,
|
||
duration: 1000
|
||
},
|
||
{
|
||
zoom: zoom,
|
||
duration: 1000
|
||
}
|
||
);
|
||
}
|
||
|
||
getCurrentZoom(): number | undefined {
|
||
return this.view?.getZoom();
|
||
}
|
||
|
||
/** 3D 专用飞入默认视角,2D 下无需操作 */
|
||
async flyToDefaultView(): Promise<void> {
|
||
// 2D 无动画,空实现
|
||
}
|
||
|
||
/**
|
||
* 生成带完整圆边框的上半圆 Canvas
|
||
* @param radius 半径(像素)
|
||
* @param color 填充颜色
|
||
* @returns HTMLCanvasElement
|
||
*/
|
||
private createSemiCircleCanvas(
|
||
radius: number,
|
||
color: string
|
||
): HTMLCanvasElement {
|
||
const size = radius * 2 + 2;
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
const ctx = canvas.getContext('2d')!;
|
||
ctx.clearRect(0, 0, size, size);
|
||
|
||
const centerX = size / 2;
|
||
const centerY = size / 2;
|
||
|
||
// 1. 绘制下半圆填充(给定颜色)— 圆弧从 π 到 2π
|
||
ctx.beginPath();
|
||
ctx.arc(centerX, centerY, radius, 0, Math.PI, false);
|
||
ctx.closePath();
|
||
ctx.fillStyle = color;
|
||
ctx.fill();
|
||
|
||
// 2. 绘制上半圆填充(白色)
|
||
ctx.beginPath();
|
||
ctx.arc(centerX, centerY, radius, Math.PI, 2 * Math.PI, false);
|
||
ctx.closePath();
|
||
ctx.fillStyle = '#FFFFFF'; // 白色
|
||
ctx.fill();
|
||
|
||
// 3. 绘制完整圆边框
|
||
ctx.beginPath();
|
||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
|
||
return canvas;
|
||
}
|
||
|
||
/**
|
||
* 移除地图对象,释放资源
|
||
*/
|
||
destroy(): void {
|
||
if (this.pointLayerStyleRefreshFrameId !== null) {
|
||
cancelAnimationFrame(this.pointLayerStyleRefreshFrameId);
|
||
this.pointLayerStyleRefreshFrameId = null;
|
||
}
|
||
if (this.labelVisibilityRefreshFrameId !== null) {
|
||
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
|
||
this.labelVisibilityRefreshFrameId = null;
|
||
}
|
||
if (this.labelVisibilityDebounceTimerId !== null) {
|
||
window.clearTimeout(this.labelVisibilityDebounceTimerId);
|
||
this.labelVisibilityDebounceTimerId = null;
|
||
}
|
||
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) {
|
||
const source = layer.getSource();
|
||
if (source && source.clear) {
|
||
source.clear();
|
||
}
|
||
}
|
||
if (this.map && this.map.getLayers().getArray().includes(layer)) {
|
||
this.map.removeLayer(layer);
|
||
}
|
||
if (layer.setMap) layer.setMap(null);
|
||
};
|
||
|
||
if (this.layerRegistry) {
|
||
this.layerRegistry.forEach(clearLayer);
|
||
this.layerRegistry.clear();
|
||
}
|
||
|
||
this.popupManager.destroy();
|
||
this.pointLayerManager.destroy();
|
||
|
||
if (this.map) {
|
||
this.map.getInteractions().clear();
|
||
this.map.getOverlays().clear();
|
||
this.map.setTarget(undefined);
|
||
this.map.dispose();
|
||
this.map = null;
|
||
}
|
||
this.popupManager.setMap(null);
|
||
this.pointLayerManager.setMap(null);
|
||
this.regionMaskManager.setContext(null, null);
|
||
|
||
if (this.view) {
|
||
this.view.dispose();
|
||
this.view = null;
|
||
}
|
||
|
||
this.baseLayerConfig = null;
|
||
this.measureSource = null;
|
||
this.measureLayer = null;
|
||
this.drawInteraction = null;
|
||
this.geoJsonData = null;
|
||
this.geoJsonData1 = null;
|
||
}
|
||
|
||
// 倾斜摄影方法仅3D支持,2D为空实现
|
||
addQxsyLayer(_item: any): void {}
|
||
removeQxsyLayer(_item: any): void {}
|
||
qxsyChangeClick(_item: any, _checked: boolean): void {}
|
||
qxsyToPosition(_item: any): void {}
|
||
}
|