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

2990 lines
90 KiB
TypeScript
Raw Normal View History

2026-04-20 16:58:33 +08:00
import { MapInterface } from './map';
import OlMap from 'ol/Map';
import View from 'ol/View';
import Overlay from 'ol/Overlay';
2026-04-20 16:58:33 +08:00
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';
2026-04-22 17:53:20 +08:00
import { get as getProjection, fromLonLat } from 'ol/proj';
2026-04-20 16:58:33 +08:00
import {
defaults as defaultInteractions,
Draw,
2026-07-01 08:48:39 +08:00
DoubleClickZoom,
DragPan
2026-04-20 16:58:33 +08:00
} 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';
2026-07-01 08:48:39 +08:00
import Point from 'ol/geom/Point';
2026-04-20 16:58:33 +08:00
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';
2026-07-01 08:48:39 +08:00
import { getNearbyPointDensityDisplayRules } from '@/modules/map/domain/nearby-point-rules';
2026-07-14 16:08:10 +08:00
import router from '@/router';
const modelStore = useModelStore();
const VITE_APP_MAP_URL = import.meta.env.VITE_APP_MAP_URL;
2026-04-20 16:58:33 +08:00
// 定义与 leaflet 中相同的常量
const CENTER_positionCN = [114.17112499999996, 38]; // OpenLayers 使用 [lon, lat]
const MIN_ZOOM = 4.23;
const MAX_ZOOM = 22;
const INITIAL_ZOOM = 4.5;
2026-07-01 08:48:39 +08:00
const BATCH_POPUP_MODE_ZOOM = 15;
2026-07-10 17:56:16 +08:00
const FULL_DISPLAY_NO_COLLISION_ZOOM = Number.POSITIVE_INFINITY;
const POINT_FADE_DURATION = 300; // 锚点/文字碰撞显隐的淡入淡出时长(ms)
// 标签文字上方的避让边距(px):防止下方锚点文字紧贴上方锚点图标底部,看着像重叠
const LABEL_ICON_TOP_CLEARANCE = 6;
2026-04-20 16:58:33 +08:00
// 定义边界 [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();
2026-07-01 08:48:39 +08:00
private iconLoadState = new Map<string, 'loading' | 'loaded' | 'error'>();
private pointStyleCache = new Map<string, Style[]>();
private measureCanvas: HTMLCanvasElement | null = null;
private measureTextCache = new Map<
string,
{ width: number; height: number }
>();
private fadeRefreshFrameId: number | null = null;
private fadingFeatures = new Set<Feature>();
private baseLayerConfig: any | null = null;
private hydropBaseConfig: any | null = null;
2026-04-20 16:58:33 +08:00
private REGISTRY_KEY = 'customBaseLayer';
private activeBaseLayerKey = 's_province_boundaries';
private readonly rasterBaseLayerKeys = ['BASEMAP-white', 'BASEMAP-img'];
2026-04-22 17:53:20 +08:00
private drawInteraction: any = null;
2026-04-20 16:58:33 +08:00
private measureLayer: VectorLayer | null = null;
private measureSource: VectorSource | null = null;
private pointLayerManager: PointLayerManager;
2026-04-22 17:53:20 +08:00
geoJsonData: any = null;
geoJsonData1: any = null;
private BASEID = '';
private currentClipGeoJson: any | null = null;
2026-07-03 15:59:05 +08:00
private clipRequestController: AbortController | null = null;
2026-07-31 11:19:35 +08:00
private wfsHighlightLayer: VectorLayer | null = null;
private popupManager: PopupManager;
private regionMaskManager: RegionMaskManager;
private isBatchPopupMode = false;
2026-07-01 08:48:39 +08:00
private batchPopupRefreshFrameId: number | null = null;
2026-07-10 17:56:16 +08:00
private batchPopupPendingRebuild = false;
private batchPopupPostRenderListenerKey: any = null;
private pointLayerStyleRefreshFrameId: number | null = null;
2026-07-01 08:48:39 +08:00
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
});
}
2026-07-03 15:59:05 +08:00
private async loadGeoJsonData(url, signal?: AbortSignal): Promise<any> {
try {
2026-07-03 15:59:05 +08:00
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
2026-07-03 15:59:05 +08:00
} catch (error: any) {
if (error.name !== 'AbortError') {
console.error('配置加载失败:', error);
}
}
}
2026-04-20 16:58:33 +08:00
//地图初始化
2026-04-22 17:53:20 +08:00
init(container: HTMLElement): Promise<any> {
2026-04-20 16:58:33 +08:00
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,
2026-04-20 16:58:33 +08:00
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,
2026-07-01 08:48:39 +08:00
dragPan: false,
2026-04-20 16:58:33 +08:00
pinchRotate: false // 通常禁用旋转,除非需要
2026-07-01 08:48:39 +08:00
}).extend([
new DragPan({
kinetic: undefined
}),
mouseWheelInteraction
])
2026-04-20 16:58:33 +08:00
});
this.pointLayerManager.setMap(this.map);
this.popupManager.setMap(this.map);
this.regionMaskManager.setContext(this.map, this.view);
2026-04-20 16:58:33 +08:00
2026-07-31 11:19:35 +08:00
// 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 => {
2026-07-14 16:08:10 +08:00
if (
router.currentRoute.value.path.includes(
'shengTaiLiuLiangManZuQingKuangJiangJu'
)
) {
return;
}
this.popupManager.showPopup(undefined, undefined);
this.popupManager.handleMapClick(evt.pixel, detectedFeature => {
2026-06-15 09:18:00 +08:00
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 + ' 详情信息';
}
});
});
2026-04-20 16:58:33 +08:00
this.map.on('pointermove', evt => {
2026-07-31 11:19:35 +08:00
// fh_qxd 河段悬停高亮
let hitLayer: any = null;
const hitFeature = this.map.forEachFeatureAtPixel(
evt.pixel,
(feature, layer) => {
hitLayer = layer;
return feature;
},
{
hitTolerance: 0,
layerFilter: layer => layer.get('key') === 'fh_qxd'
}
);
// 高亮图层存在时,命中要素复制到高亮图层,未命中则清空
if (this.wfsHighlightLayer) {
const highlightSource = this.wfsHighlightLayer.getSource();
if (hitFeature) {
// 根据命中图层的底色动态调整高亮颜色(统一加深)
const baseColor = hitLayer?.get('bjColor') || '';
// 读取命中图层原始描边宽度,保持高亮只变颜色不加粗
let highlightWidth = 5;
const layerStyle = hitLayer?.getStyle?.();
if (
layerStyle &&
typeof layerStyle === 'object' &&
!Array.isArray(layerStyle)
) {
const strokeWidth = (layerStyle as Style)
.getStroke?.()
?.getWidth?.();
if (typeof strokeWidth === 'number' && strokeWidth > 0) {
highlightWidth = strokeWidth;
}
}
this.wfsHighlightLayer.setStyle(
new Style({
stroke: new Stroke({
color: '#37ff00ff',
width: 4
})
})
);
highlightSource?.clear();
highlightSource?.addFeature(hitFeature);
} else {
highlightSource?.clear();
}
}
// 命中河段或锚点时直接切换鼠标样式(与 demo 一致popup 是节流回调不能依赖它)
2026-07-31 11:19:35 +08:00
const targetElement = this.map?.getTargetElement() as HTMLElement;
let isHitAnchor = false;
2026-07-31 11:19:35 +08:00
if (targetElement) {
isHitAnchor = this.detectFeatureAtPixel(evt.pixel).isHitIcon;
targetElement.style.cursor =
isHitAnchor || hitFeature ? 'pointer' : '';
2026-07-31 11:19:35 +08:00
}
this.popupManager.handlePointerMove(evt.pixel, payload => {
if (targetElement) {
2026-07-31 11:19:35 +08:00
// 命中河段或 hover popup 命中锚点时显示 pointer 光标
targetElement.style.cursor =
isHitAnchor || payload.hoveredId || hitFeature ? 'pointer' : '';
2026-04-20 16:58:33 +08:00
}
// 该锚点已有固定展示(批量 popup的 popup跳过 hover popup避免同一内容出现双份
// 未被固定展示的锚点(如碰撞被隐藏)仍可通过 hover 查看,且 hover popup 展示时会临时提升层级,不被固定展示遮挡
const fixedShown =
payload.detectedFeature?.get('_fixedPopupShown') === true;
this.showPopup(
fixedShown ? undefined : payload.detectedFeature,
fixedShown ? undefined : payload.coordinate
);
});
2026-04-20 16:58:33 +08:00
});
this.map.on('pointerdrag', () => {
if (this.isBatchPopupMode) {
2026-07-10 17:56:16 +08:00
this.ensureBatchPopupPostRenderSync();
}
});
this.map.on('moveend', () => {
2026-07-10 17:56:16 +08:00
this.clearBatchPopupPostRenderSync();
this.requestRefreshPointLabelVisibility(false, 60);
if (this.isBatchPopupMode) {
2026-07-10 17:56:16 +08:00
this.requestUpdateBatchPopups(false, true);
}
});
2026-04-20 16:58:33 +08:00
return Promise.resolve(this.map);
} catch (e) {
console.error('OL Init Error', e);
2026-04-20 16:58:33 +08:00
return Promise.reject(e);
}
}
/**
*
* @param pointData
* @param layerType /Key
* @param mdoptions
*/
addInitDataLayer(
pointData: any,
layerType: any,
_mdoptions?: MDOptions
2026-04-20 16:58:33 +08:00
): void {
this.pointLayerManager.addDataLayer(pointData, layerType);
2026-07-03 15:59:05 +08:00
if (this.currentClipGeoJson) {
this.regionMaskManager.filterPointsByRegion(this.currentClipGeoJson);
}
2026-07-01 08:48:39 +08:00
const currentZoom = this.view ? this.view.getZoom() : INITIAL_ZOOM;
this.pointLayerManager.updateNearbyFeatureLayout(currentZoom);
this.requestRefreshPointLabelVisibility(false, 80);
2026-04-20 16:58:33 +08:00
}
/**
* 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);
}
/**
2026-07-01 08:48:39 +08:00
* popup
*/
private handleZoomChange() {
if (!this.view) return;
const zoom = this.view.getZoom();
if (zoom === undefined) return;
2026-07-01 08:48:39 +08:00
this.pointLayerManager.updateNearbyFeatureLayout(zoom);
this.requestRefreshPointLabelVisibility();
if (zoom >= BATCH_POPUP_MODE_ZOOM) {
this.enableBatchPopupMode();
} else {
this.disableBatchPopupMode();
}
}
/**
* popup popup
*/
private enableBatchPopupMode() {
this.isBatchPopupMode = true;
2026-07-10 17:56:16 +08:00
this.clearBatchPopupPostRenderSync();
this.popupManager.clearBatchPopups();
2026-07-10 17:56:16 +08:00
this.requestUpdateBatchPopups(true, true);
}
/**
* popup popup
*/
private disableBatchPopupMode() {
this.isBatchPopupMode = false;
2026-07-10 17:56:16 +08:00
this.batchPopupPendingRebuild = false;
this.clearBatchPopupPostRenderSync();
2026-07-01 08:48:39 +08:00
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/距离过滤等隐藏的锚点
2026-07-10 17:56:16 +08:00
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;
});
});
}
2026-07-10 17:56:16 +08:00
private clearBatchPopupPostRenderSync() {
if (this.batchPopupPostRenderListenerKey) {
unByKey(this.batchPopupPostRenderListenerKey);
this.batchPopupPostRenderListenerKey = null;
}
}
2026-04-20 16:58:33 +08:00
/**
* ( Leaflet DivIcon )
*/
2026-07-01 08:48:39 +08:00
private createPointStyle(feature: Feature): Style[] | null {
2026-04-20 16:58:33 +08:00
const iconUrl = feature.get('_iconUrl') as string;
const labelText = feature.get('_labelText') as string;
const legendVisible = feature.get('_legendVisible');
const regionVisible = feature.get('_regionVisible');
2026-04-20 16:58:33 +08:00
if (!iconUrl) {
return null;
2026-04-20 16:58:33 +08:00
}
2026-07-01 08:48:39 +08:00
if (!this.ensureIconReady(iconUrl)) {
return null;
}
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);
2026-07-01 08:48:39 +08:00
const iconTargetVisible =
legendVisible !== false &&
regionVisible !== false &&
densityVisible &&
this.shouldRenderNearbyFeature();
2026-07-01 08:48:39 +08:00
const dynamicScale = this.getDynamicPointScale(currentZoom);
const fontSize = this.getPointFontSize(dynamicScale);
2026-07-10 17:56:16 +08:00
const engPriority = this.getFeatureEngRenderPriority(feature);
2026-06-15 09:18:00 +08:00
const formattedLabelText = this.formatPointLabelText(labelText);
const labelLineCount = formattedLabelText
? formattedLabelText.split('\n').length
: 1;
2026-07-01 08:48:39 +08:00
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;
2026-07-01 08:48:39 +08:00
const finalIconVisible = iconTargetVisible && iconCollisionVisible;
// 碰撞隐藏时仍在淡出中则继续渲染动画结束alpha 归 0后才会真正隐藏
const iconRendered = finalIconVisible || isIconFading;
2026-07-01 08:48:39 +08:00
// 图例/区域/密度隐藏:不做淡出,直接隐藏
if (!iconTargetVisible) {
2026-07-01 08:48:39 +08:00
return null;
}
2026-04-20 16:58:33 +08:00
// 碰撞隐藏且不在淡出中:直接隐藏
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,
2026-07-10 17:56:16 +08:00
engPriority,
labelOffsetY,
labelRenderVisible ? formattedLabelText : '',
labelRenderVisible ? 1 : 0,
fading ? 1 : 0
].join('|');
const cachedStyles = this.pointStyleCache.get(styleCacheKey);
if (cachedStyles) {
return cachedStyles;
}
const styles: Style[] = [
2026-07-01 08:48:39 +08:00
new Style({
2026-07-10 17:56:16 +08:00
zIndex: engPriority,
2026-07-01 08:48:39 +08:00
image: new Icon({
src: iconUrl,
scale: dynamicScale,
anchor: [0.5, 0.5],
crossOrigin: 'anonymous',
declutterMode: 'none',
opacity: iconRenderAlpha
2026-07-01 08:48:39 +08:00
})
2026-04-20 16:58:33 +08:00
})
];
2026-07-01 08:48:39 +08:00
if (formattedLabelText && labelRenderVisible) {
2026-07-01 08:48:39 +08:00
styles.push(
new Style({
2026-07-10 17:56:16 +08:00
zIndex: engPriority + 1,
2026-07-01 08:48:39 +08:00
text: new Text({
text: formattedLabelText,
offsetY: labelOffsetY,
font: `${fontSize}px sans-serif`,
fill: new Fill({
color: `rgba(255, 255, 255, ${effectiveLabelAlpha})`
}),
2026-07-01 08:48:39 +08:00
stroke: new Stroke({
color: `rgba(0, 0, 0, ${0.9 * effectiveLabelAlpha})`,
2026-07-01 08:48:39 +08:00
width: 2
}),
textAlign: 'center',
declutterMode: 'none'
})
})
);
}
if (!fading) {
this.pointStyleCache.set(styleCacheKey, styles);
}
2026-07-01 08:48:39 +08:00
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();
});
}
/**
* iconlabel/
* show=false +
* createPointStyle Cesium alpha alpha
*/
private startFade(
feature: Feature,
dir: 'in' | 'out',
target: 'icon' | 'label'
) {
const outFlag = target === 'icon' ? '_isFadingOut' : '_labelFadingOut';
const outStart =
target === 'icon' ? '_fadeStartTime' : '_labelFadeStartTime';
const inFlag = target === 'icon' ? '_isFadingIn' : '_labelFadingIn';
const inStart =
target === 'icon' ? '_fadeInStartTime' : '_labelFadeInStartTime';
if (dir === 'out') {
if (feature.get(outFlag)) return;
feature.set(outFlag, true, true);
feature.set(outStart, performance.now(), true);
// 若正在淡入则中断,改向淡出
feature.set(inFlag, false, true);
feature.set(inStart, 0, true);
} else {
if (feature.get(inFlag)) return;
feature.set(inFlag, true, true);
feature.set(inStart, performance.now(), true);
// 若正在淡出则中断,改向淡入
feature.set(outFlag, false, true);
feature.set(outStart, 0, true);
}
this.fadingFeatures.add(feature);
this.requestFadeRefresh();
}
/**
* / alpha
* alpha 1.00.0 0.01.0 null
* null
* "结束时可安全置 show=false"
*/
private getFadeAlpha(
feature: Feature,
target: 'icon' | 'label'
): number | null {
const outFlag = target === 'icon' ? '_isFadingOut' : '_labelFadingOut';
const outStart =
target === 'icon' ? '_fadeStartTime' : '_labelFadeStartTime';
const inFlag = target === 'icon' ? '_isFadingIn' : '_labelFadingIn';
const inStart =
target === 'icon' ? '_fadeInStartTime' : '_labelFadeInStartTime';
if (feature.get(outFlag)) {
const alpha = this.computeFadeAlpha(feature.get(outStart), false);
if (alpha === null) {
// 淡出结束:自动清理标记
feature.set(outFlag, false, true);
feature.set(outStart, 0, true);
}
return alpha;
}
if (feature.get(inFlag)) {
const alpha = this.computeFadeAlpha(feature.get(inStart), true);
if (alpha === null) {
// 淡入结束:自动清理标记
feature.set(inFlag, false, true);
feature.set(inStart, 0, true);
}
return alpha;
}
return null;
}
/** 根据起始时间与当前时间计算 alpha动画已结束返回 null */
private computeFadeAlpha(
startTime: unknown,
fadingIn: boolean
): number | null {
const start = Number(startTime) || 0;
const elapsed = performance.now() - start;
if (elapsed >= POINT_FADE_DURATION) {
return null;
}
const progress = Math.max(0, elapsed / POINT_FADE_DURATION);
return fadingIn ? progress : 1 - progress;
}
/** 请求一次淡变重绘;已有循环在跑则忽略 */
private requestFadeRefresh() {
if (this.fadeRefreshFrameId !== null) return;
this.fadeRefreshFrameId = requestAnimationFrame(() =>
this.tickFadeRefresh()
);
}
/**
* createPointStyle alpha
* feature getFadeAlpha
*/
private tickFadeRefresh() {
this.fadeRefreshFrameId = null;
const stillFading: Feature[] = [];
this.fadingFeatures.forEach(feature => {
if (
this.getFadeAlpha(feature, 'icon') !== null ||
this.getFadeAlpha(feature, 'label') !== null
) {
stillFading.push(feature);
}
});
this.fadingFeatures = new Set(stillFading);
this.pointLayerManager.forEachLayer(layer => layer.changed());
if (this.fadingFeatures.size > 0) {
this.fadeRefreshFrameId = requestAnimationFrame(() =>
this.tickFadeRefresh()
);
}
}
private syncFeatureDensityVisible(
feature: Feature,
visible: boolean,
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;
}
2026-07-01 08:48:39 +08:00
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;
2026-07-10 17:56:16 +08:00
engPriority: number;
2026-07-01 08:48:39 +08:00
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;
2026-07-10 17:56:16 +08:00
engPriority: number;
2026-07-01 08:48:39 +08:00
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;
}> = [];
2026-07-01 08:48:39 +08:00
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(
2026-07-01 08:48:39 +08:00
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
2026-07-01 08:48:39 +08:00
);
const bucket = densityPixelGrid.get(gridKey) || [];
bucket.push({
feature,
layerKey: entry.layerKey,
pixelX: pixel[0],
pixelY: pixel[1]
});
densityPixelGrid.set(gridKey, bucket);
}
});
2026-07-01 08:48:39 +08:00
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
);
2026-07-01 08:48:39 +08:00
this.syncFeatureLabelCollisionVisible(
entry.feature,
shouldRenderIcon && hasLabel,
dirtyLayerKeys
2026-07-01 08:48:39 +08:00
);
});
this.flushDirtyPointLayers(dirtyLayerKeys);
2026-07-01 08:48:39 +08:00
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
);
2026-07-01 08:48:39 +08:00
const shouldRenderIcon =
!!entry.iconUrl &&
this.isIconReady(entry.iconUrl) &&
entry.legendVisible &&
entry.regionVisible &&
densityVisible &&
entry.nearbyVisible;
2026-07-01 08:48:39 +08:00
if (!shouldRenderIcon) {
this.syncFeatureIconCollisionVisible(
entry.feature,
false,
dirtyLayerKeys
);
this.syncFeatureLabelCollisionVisible(
entry.feature,
false,
dirtyLayerKeys
);
2026-07-01 08:48:39 +08:00
return;
}
if (entry.pixelX === undefined || entry.pixelY === undefined) {
this.syncFeatureIconCollisionVisible(
entry.feature,
false,
dirtyLayerKeys
);
this.syncFeatureLabelCollisionVisible(
entry.feature,
false,
dirtyLayerKeys
);
2026-07-01 08:48:39 +08:00
return;
}
const dynamicScale = this.getDynamicPointScale(currentZoom);
// 备注:图标碰撞盒适当小于视觉图标尺寸,允许相邻站点轻微贴近显示,
// 避免像“小浪底/三门峡”这类近点被过早裁掉成只剩一个点。
2026-07-10 17:56:16 +08:00
const iconCollisionSize = Math.max(14, 24 * dynamicScale);
2026-07-01 08:48:39 +08:00
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;
2026-07-01 08:48:39 +08:00
const fontSize = this.getPointFontSize(dynamicScale);
const labelLineCount = labelText ? labelText.split('\n').length : 1;
// 碰撞偏移与渲染偏移统一,保证碰撞块与实际渲染文字完全重合
const labelOffsetY = this.getPointLabelRenderOffsetY(
2026-07-01 08:48:39 +08:00
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;
2026-07-01 08:48:39 +08:00
const candidateId = String(
entry.feature.getId?.() || entry.feature.get('stcd') || ''
2026-07-01 08:48:39 +08:00
);
const dedupeKey =
candidateId ||
[
entry.feature.get('_layerKey') || '',
entry.pixelX.toFixed(2),
entry.pixelY.toFixed(2),
2026-07-01 08:48:39 +08:00
labelText
].join('|');
candidateMap.set(dedupeKey, {
feature: entry.feature,
2026-07-01 08:48:39 +08:00
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),
2026-07-10 17:56:16 +08:00
engPriority: this.getFeatureEngRenderPriority(entry.feature),
densityPriority: entry.densityPriority,
2026-07-01 08:48:39 +08:00
id: candidateId,
pixelX: entry.pixelX,
pixelY: entry.pixelY
2026-07-01 08:48:39 +08:00
});
});
candidateMap.forEach(candidate => {
candidates.push(candidate);
});
candidates.sort((left, right) => {
2026-07-10 17:56:16 +08:00
if (left.engPriority !== right.engPriority) {
return right.engPriority - left.engPriority;
}
2026-07-01 08:48:39 +08:00
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;
}>
>();
2026-07-01 08:48:39 +08:00
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;
2026-07-01 08:48:39 +08:00
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)
2026-07-01 08:48:39 +08:00
);
const iconVisible = !iconHasCollision;
this.syncFeatureIconCollisionVisible(
candidate.feature,
iconVisible,
dirtyLayerKeys
);
2026-07-01 08:48:39 +08:00
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
);
2026-07-01 08:48:39 +08:00
}
});
const labelCandidates = [...candidates].sort((left, right) => {
2026-07-10 17:56:16 +08:00
if (left.engPriority !== right.engPriority) {
return right.engPriority - left.engPriority;
}
2026-07-01 08:48:39 +08:00
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
);
2026-07-01 08:48:39 +08:00
return;
}
if (!iconVisible) {
this.syncFeatureLabelCollisionVisible(
candidate.feature,
false,
dirtyLayerKeys
);
2026-07-01 08:48:39 +08:00
return;
}
const collidingIconRect = this.findCollidingRectInGrid(
candidate,
placedIconGrid,
collisionGridCellSize,
rect =>
rect.featureId !== featureId &&
this.checkLabelCollisionWithIcon(rect, candidate)
);
2026-07-01 08:48:39 +08:00
const hasCollisionWithIcon = !!collidingIconRect;
const collidingLabelRect = this.findCollidingRectInGrid(
candidate,
placedLabelGrid,
collisionGridCellSize,
rect => this.checkLabelCollision(rect, candidate)
2026-07-01 08:48:39 +08:00
);
const hasCollisionWithLabel = !!collidingLabelRect;
const visible = !hasCollisionWithIcon && !hasCollisionWithLabel;
this.syncFeatureLabelCollisionVisible(
candidate.feature,
visible,
dirtyLayerKeys
);
2026-07-01 08:48:39 +08:00
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
);
2026-07-01 08:48:39 +08:00
}
});
this.flushDirtyPointLayers(dirtyLayerKeys);
2026-07-01 08:48:39 +08:00
}
private requestRefreshPointLabelVisibility(
immediate = false,
debounceMs = 0
) {
if (this.labelVisibilityDebounceTimerId !== null) {
window.clearTimeout(this.labelVisibilityDebounceTimerId);
this.labelVisibilityDebounceTimerId = null;
}
2026-07-01 08:48:39 +08:00
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;
}
2026-07-01 08:48:39 +08:00
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
);
2026-07-01 08:48:39 +08:00
}
private syncFeatureLabelCollisionVisible(
feature: Feature,
visible: boolean,
dirtyLayerKeys?: Set<string>
) {
this.markFeatureVisibilityDirty(
feature,
'_labelCollisionVisible',
visible,
dirtyLayerKeys
);
2026-07-01 08:48:39 +08:00
}
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.iconLoadState.set(iconUrl, 'loaded');
this.requestRefreshPointLayerStyles();
2026-07-01 08:48:39 +08:00
};
image.onerror = () => {
this.iconLoadState.set(iconUrl, 'error');
this.requestRefreshPointLayerStyles();
};
2026-07-01 08:48:39 +08:00
image.src = iconUrl;
if (image.complete && image.naturalWidth > 0) {
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();
});
}
2026-07-01 08:48:39 +08:00
private refreshPointLayerStyles() {
this.pointLayerManager.forEachLayer(layer => {
layer.changed();
2026-04-20 16:58:33 +08:00
});
this.requestRefreshPointLabelVisibility();
2026-07-01 08:48:39 +08:00
if (this.isBatchPopupMode) {
this.requestUpdateBatchPopups();
2026-07-01 08:48:39 +08:00
}
}
2026-07-10 17:56:16 +08:00
private requestUpdateBatchPopups(immediate = false, rebuild = true) {
2026-07-01 08:48:39 +08:00
if (!this.isBatchPopupMode) {
return;
}
2026-07-10 17:56:16 +08:00
this.batchPopupPendingRebuild = this.batchPopupPendingRebuild || rebuild;
2026-07-01 08:48:39 +08:00
if (immediate) {
if (this.batchPopupRefreshFrameId !== null) {
cancelAnimationFrame(this.batchPopupRefreshFrameId);
this.batchPopupRefreshFrameId = null;
}
this.updateBatchPopups();
2026-07-10 17:56:16 +08:00
this.batchPopupPendingRebuild = false;
2026-07-01 08:48:39 +08:00
return;
}
if (this.batchPopupRefreshFrameId !== null) {
return;
}
this.batchPopupRefreshFrameId = window.requestAnimationFrame(() => {
this.batchPopupRefreshFrameId = null;
if (!this.isBatchPopupMode) {
return;
}
this.updateBatchPopups();
2026-07-10 17:56:16 +08:00
this.batchPopupPendingRebuild = false;
2026-07-01 08:48:39 +08:00
});
}
/** 复用离屏 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;
}
2026-07-01 08:48:39 +08:00
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
};
2026-07-01 08:48:39 +08:00
return !(
narrowedIconRect.right + padding <= extendedLabelRect.left ||
narrowedIconRect.left - padding >= extendedLabelRect.right ||
narrowedIconRect.bottom + padding <= extendedLabelRect.top ||
narrowedIconRect.top - padding >= extendedLabelRect.bottom
2026-07-01 08:48:39 +08:00
);
2026-04-20 16:58:33 +08:00
}
2026-06-15 09:18:00 +08:00
/**
* 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));
}
/**
2026-07-01 08:48:39 +08:00
*
*/
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;
}
2026-07-10 17:56:16 +08:00
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_')
);
}
2026-07-10 17:56:16 +08:00
private getFeatureEngRenderPriority(feature: Feature): number {
if (this.isFeatureEngAlarm(feature)) {
return 300;
}
2026-07-10 17:56:16 +08:00
return this.isFeatureEng(feature) ? 200 : 100;
}
2026-07-01 08:48:39 +08:00
/**
*
* 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;
}
/**
*
2026-07-01 08:48:39 +08:00
*/
private shouldRenderNearbyFeature(): boolean {
return true;
}
2026-04-20 16:58:33 +08:00
/**
*
* @param layer
*/
2026-07-03 15:59:05 +08:00
addBaseDataLayer(layer: any, isShow: boolean, isCache = false): void {
2026-04-20 16:58:33 +08:00
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);
}
2026-04-20 16:58:33 +08:00
if (layer.type === 'wmts') {
if (!layer.url) return;
2026-07-03 15:59:05 +08:00
const url = !isCache ? layer.url : layer.url_3d;
2026-04-20 16:58:33 +08:00
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');
2026-04-20 16:58:33 +08:00
const projection = getProjection('EPSG:3857');
if (!projection) {
console.error('无法获取 EPSG:3857 投影');
return;
}
const projectionExtent = projection.getExtent();
const size = getWidth(projectionExtent) / 256;
const maxZoom = 13;
2026-04-20 16:58:33 +08:00
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],
2026-04-20 16:58:33 +08:00
layer: layerName,
matrixSet: matrixSetName,
format: 'image/png',
projection: projection,
2026-04-20 16:58:33 +08:00
tileGrid: new WMTSTileGrid({
origin: getTopLeft(projectionExtent),
2026-04-20 16:58:33 +08:00
resolutions: resolutions,
matrixIds: matrixIds
2026-04-20 16:58:33 +08:00
}),
style: 'default',
wrapX: true,
crossOrigin: 'anonymous'
2026-04-20 16:58:33 +08:00
})
});
if (layer.key === this.REGISTRY_KEY) {
!isCache ? wmtsLayer.setZIndex(-100) : wmtsLayer.setZIndex(-99);
} else {
wmtsLayer.setZIndex(-100);
2026-04-20 16:58:33 +08:00
}
2026-07-31 11:19:35 +08:00
wmtsLayer.set('key', layer.key);
wmtsLayer.type = 'layer';
wmtsLayer.setVisible(isShow);
2026-04-20 16:58:33 +08:00
this.layerRegistry.set(layer.key, wmtsLayer);
this.map.addLayer(wmtsLayer);
layer._layer = wmtsLayer;
2026-07-31 11:19:35 +08:00
} 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;
2026-04-20 16:58:33 +08:00
} else if (layer.type == 'raster-dem') {
const tileLayer = new TileLayer({
source: new XYZ({
url: layer.url,
2026-07-31 11:19:35 +08:00
wrapX: false,
2026-04-20 16:58:33 +08:00
crossOrigin: 'anonymous'
})
});
2026-07-31 11:19:35 +08:00
tileLayer.set('key', layer.key);
2026-04-20 16:58:33 +08:00
tileLayer.setZIndex(-100);
tileLayer.setVisible(isShow);
tileLayer.type = 'layer';
this.layerRegistry.set(layer.key, tileLayer);
2026-04-20 16:58:33 +08:00
this.map.addLayer(tileLayer);
} else if (layer.type === 'vector') {
if (layer.key === 'hydropBase') {
this.hydropBaseConfig = layer;
2026-04-20 16:58:33 +08:00
}
}
}
/**
* 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('未找到全量底图图层或图层类型错误');
}
}
2026-04-20 16:58:33 +08:00
/**
*
* @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;
}
2026-07-03 15:59:05 +08:00
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();
2026-07-03 15:59:05 +08:00
this.clipRequestController = new AbortController();
const signal = this.clipRequestController.signal;
try {
const url =
this.hydropBaseConfig.geojson_url +
`&cql_filter=BASEID='${this.BASEID}'`;
2026-07-03 15:59:05 +08:00
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);
2026-07-03 15:59:05 +08:00
} catch (error: any) {
if (error.name === 'AbortError') {
return;
}
this.currentClipGeoJson = null;
console.error('加载裁切数据失败:', error);
this.regionMaskManager.clearMapMask();
this.regionMaskManager.showAllPoints();
2026-07-03 15:59:05 +08:00
} finally {
if (this.clipRequestController?.signal === signal) {
this.clipRequestController = null;
}
}
2026-04-20 16:58:33 +08:00
}
/**
*
* @param layerType (使 key)
* @param key Key
* @param checked true false
*/
controlBaseLayerTreeShowAndHidden(
layerType: string,
key: string,
2026-04-20 16:58:33 +08:00
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;
}
2026-04-20 16:58:33 +08:00
const layerInstance = this.layerRegistry.get(registryKey as string);
if (layerInstance) {
layerInstance.setVisible(checked);
// 图层显隐变化时刷新 batch popup
if (this.isBatchPopupMode) {
2026-07-01 08:48:39 +08:00
this.requestUpdateBatchPopups(true);
}
2026-04-20 16:58:33 +08:00
} else {
console.warn(
`未找到标识为 [${registryKey}] 的图层实例,当前注册表 keys:`,
Array.from(this.layerRegistry.keys())
);
}
}
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {
this.pointLayerManager.setLayerVisible(layerType, checked);
2026-07-01 08:48:39 +08:00
this.requestRefreshPointLabelVisibility(true);
// 图层显隐变化时刷新 batch popup
if (this.isBatchPopupMode) {
2026-07-01 08:48:39 +08:00
this.requestUpdateBatchPopups(true);
}
2026-04-20 16:58:33 +08:00
}
2026-07-01 08:48:39 +08:00
hasLayer(layerType: string): boolean {
return this.pointLayerManager.hasLayer(layerType as string);
}
2026-07-01 08:48:39 +08:00
hasBaseLayer(layerKey: string): boolean {
return this.layerRegistry.has(layerKey);
}
2026-04-20 16:58:33 +08:00
/**
*
* @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 {
2026-04-20 16:58:33 +08:00
if (!this.map || !this.view) return;
this.activeBaseLayerKey = key;
2026-04-20 16:58:33 +08:00
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);
2026-04-20 16:58:33 +08:00
} 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);
}
});
2026-04-20 16:58:33 +08:00
}
this.reapplyCurrentBaseMask();
2026-04-20 16:58:33 +08:00
}
/**
*
* @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);
}
});
}
2026-04-20 16:58:33 +08:00
/**
* (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. 检查是否已存在该图层,已存在时直接切换为显示并刷新样式
2026-04-20 16:58:33 +08:00
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?.() ?? [];
2026-04-20 16:58:33 +08:00
existingFeatures.forEach(feature => {
const rvcd = feature.get('RVCD');
const isVisible =
allowedIds.length > 0 ? rvcd && allowedIds.includes(rvcd) : true;
2026-04-20 16:58:33 +08:00
feature.setStyle(isVisible ? visibleStyle : hiddenStyle);
});
2026-04-20 16:58:33 +08:00
existingLayer.setVisible(layer.visible !== false);
return;
}
2026-04-20 16:58:33 +08:00
// 3. 创建矢量源 (VectorSource),并通过 layer.geojson_url 加载数据
const vectorSource = new VectorSource();
2026-04-20 16:58:33 +08:00
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);
});
}
);
}
2026-04-20 16:58:33 +08:00
// 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);
}
/**
*
2026-04-20 16:58:33 +08:00
* @param layer ( key )
*/
hideTertiarybasinLayer(layer: any): void {
2026-04-20 16:58:33 +08:00
if (!this.map || !layer || !layer.key) {
console.warn('hideTertiarybasinLayer: 无效的图层或地图未初始化');
2026-04-20 16:58:33 +08:00
return;
}
// 1. 从注册表中获取图层实例
const layerInstance = this.layerRegistry.get(layer.key);
if (layerInstance) {
layerInstance.setVisible(false);
2026-04-20 16:58:33 +08:00
} else {
console.warn(`未找到标识为 [${layer.key}] 的梯级流域图层实例`);
}
}
/**
*
* @param layerKey key
*/
removePointLayer(layerKey: string): void {
this.pointLayerManager.removeLayer(layerKey);
}
2026-04-20 16:58:33 +08:00
/**
* /
* 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({
2026-04-22 17:53:20 +08:00
source: this.measureSource!,
2026-04-20 16:58:33 +08:00
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) {
2026-07-01 08:48:39 +08:00
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
);
2026-07-01 08:48:39 +08:00
this.requestRefreshPointLabelVisibility(true);
// 图例变化时刷新 batch popup
if (this.isBatchPopupMode) {
2026-07-01 08:48:39 +08:00
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
}
);
}
2026-07-10 17:56:16 +08:00
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;
}
2026-04-20 16:58:33 +08:00
/**
*
*/
destroy(): void {
if (this.pointLayerStyleRefreshFrameId !== null) {
cancelAnimationFrame(this.pointLayerStyleRefreshFrameId);
this.pointLayerStyleRefreshFrameId = null;
}
2026-07-01 08:48:39 +08:00
if (this.labelVisibilityRefreshFrameId !== null) {
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
this.labelVisibilityRefreshFrameId = null;
}
if (this.labelVisibilityDebounceTimerId !== null) {
window.clearTimeout(this.labelVisibilityDebounceTimerId);
this.labelVisibilityDebounceTimerId = null;
}
2026-07-10 17:56:16 +08:00
this.clearBatchPopupPostRenderSync();
2026-04-20 16:58:33 +08:00
this.removeQueryLayer();
this.regionMaskManager.destroy();
this.measureTextCache.clear();
this.measureCanvas = null;
if (this.fadeRefreshFrameId !== null) {
cancelAnimationFrame(this.fadeRefreshFrameId);
this.fadeRefreshFrameId = null;
}
this.fadingFeatures.clear();
2026-04-20 16:58:33 +08:00
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);
2026-04-20 16:58:33 +08:00
this.layerRegistry.clear();
}
this.popupManager.destroy();
this.pointLayerManager.destroy();
2026-04-20 16:58:33 +08:00
if (this.map) {
this.map.getInteractions().clear();
this.map.getOverlays().clear();
2026-04-22 17:53:20 +08:00
this.map.setTarget(undefined);
2026-04-20 16:58:33 +08:00
this.map.dispose();
this.map = null;
}
this.popupManager.setMap(null);
this.pointLayerManager.setMap(null);
this.regionMaskManager.setContext(null, null);
2026-04-20 16:58:33 +08:00
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;
2026-04-20 16:58:33 +08:00
}
// 倾斜摄影方法仅3D支持2D为空实现
addQxsyLayer(_item: any): void {}
removeQxsyLayer(_item: any): void {}
qxsyChangeClick(_item: any, _checked: boolean): void {}
qxsyToPosition(_item: any): void {}
2026-04-20 16:58:33 +08:00
}