WholeProcessPlatform/frontend/src/components/gis/map.ol.ts

3031 lines
92 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// 淡入淡出 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,
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();
// 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 }
) {
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 = '&times;';
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 {}
}