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

2193 lines
65 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 Circle from 'ol/style/Circle';
import RegularShape from 'ol/style/RegularShape';
import Cluster from 'ol/source/Cluster';
2026-04-20 16:58:33 +08:00
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';
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;
const FULL_DISPLAY_NO_COLLISION_ZOOM = 15;
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 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;
private hoveredFeatureId: string | number | null = null;
private popupManager: PopupManager;
private regionMaskManager: RegionMaskManager;
private isBatchPopupMode = false;
2026-07-01 08:48:39 +08:00
private batchPopupRefreshFrameId: number | null = null;
private labelVisibilityRefreshFrameId: 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): Promise<any> {
try {
const response = await fetch(url);
const data = await response.json();
return data;
} catch (error) {
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
this.view.on('change:resolution', () => {
this.handleZoomChange();
});
this.map.on('click', evt => {
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 => {
this.popupManager.handlePointerMove(evt.pixel, payload => {
this.hoveredFeatureId = payload.hoveredId;
2026-04-20 16:58:33 +08:00
const targetElement = this.map?.getTargetElement() as HTMLElement;
if (targetElement) {
targetElement.style.cursor = payload.hoveredId ? 'pointer' : '';
2026-04-20 16:58:33 +08:00
}
2026-07-01 08:48:39 +08:00
// 批量 popup 模式下不触发 hover popup
if (!this.isBatchPopupMode) {
this.showPopup(payload.detectedFeature, payload.coordinate);
}
});
2026-04-20 16:58:33 +08:00
});
this.map.on('pointerdrag', () => {
if (this.isBatchPopupMode) {
2026-07-01 08:48:39 +08:00
this.requestUpdateBatchPopups();
}
});
this.map.on('moveend', () => {
2026-07-01 08:48:39 +08:00
this.requestRefreshPointLabelVisibility(true);
if (this.isBatchPopupMode) {
2026-07-01 08:48:39 +08:00
this.requestUpdateBatchPopups(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-01 08:48:39 +08:00
const currentZoom = this.view ? this.view.getZoom() : INITIAL_ZOOM;
this.pointLayerManager.updateNearbyFeatureLayout(currentZoom);
this.requestRefreshPointLabelVisibility(true);
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;
this.popupManager.clearBatchPopups();
2026-07-01 08:48:39 +08:00
this.requestUpdateBatchPopups(true);
}
/**
* popup popup
*/
private disableBatchPopupMode() {
this.isBatchPopupMode = false;
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/距离过滤等隐藏的锚点
this.popupManager.showPopupsForFeatures(features, feature => {
const style = this.createPointStyle(feature);
return style !== 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;
2026-07-01 08:48:39 +08:00
const iconTargetVisible =
legendVisible !== false &&
regionVisible !== false &&
this.shouldRenderFeatureByDensity(feature, currentZoom) &&
this.shouldRenderNearbyFeature(feature, currentZoom);
const dynamicScale = this.getDynamicPointScale(currentZoom);
const fontSize = this.getPointFontSize(dynamicScale);
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;
const finalIconVisible = iconTargetVisible && iconCollisionVisible;
const labelTargetVisible =
finalIconVisible && !!formattedLabelText && labelCollisionVisible;
if (!finalIconVisible) {
return null;
}
2026-04-20 16:58:33 +08:00
2026-07-01 08:48:39 +08:00
const styles: Style[] = [];
styles.push(
new Style({
image: new Icon({
src: iconUrl,
scale: dynamicScale,
anchor: [0.5, 0.5],
crossOrigin: 'anonymous',
declutterMode: 'none'
})
2026-04-20 16:58:33 +08:00
})
2026-07-01 08:48:39 +08:00
);
if (formattedLabelText && labelTargetVisible) {
styles.push(
new Style({
zIndex: 101,
text: new Text({
text: formattedLabelText,
offsetY: labelOffsetY,
font: `${fontSize}px sans-serif`,
fill: new Fill({ color: '#fff' }),
stroke: new Stroke({
color: 'rgba(0, 0, 0, 0.9)',
width: 2
}),
textAlign: 'center',
declutterMode: 'none'
})
})
);
}
return styles;
}
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;
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;
densityPriority: number;
id: string;
pixelX: number;
pixelY: number;
}> = [];
const viewportFeatures = this.pointLayerManager.getFeaturesInViewport();
if (currentZoom >= FULL_DISPLAY_NO_COLLISION_ZOOM) {
viewportFeatures.forEach(feature => {
const iconUrl = feature.get('_iconUrl') as string;
const shouldRenderIcon =
!!iconUrl &&
this.isIconReady(iconUrl) &&
feature.get('_legendVisible') !== false &&
feature.get('_regionVisible') !== false &&
this.shouldRenderFeatureByDensity(feature, currentZoom) &&
this.shouldRenderNearbyFeature(feature, currentZoom);
const hasLabel = !!this.formatPointLabelText(
feature.get('_labelText') as string
);
this.syncFeatureIconCollisionVisible(feature, shouldRenderIcon);
this.syncFeatureLabelCollisionVisible(
feature,
shouldRenderIcon && hasLabel
);
});
return;
}
viewportFeatures.forEach(feature => {
const iconUrl = feature.get('_iconUrl') as string;
const shouldRenderIcon =
!!iconUrl &&
this.isIconReady(iconUrl) &&
feature.get('_legendVisible') !== false &&
feature.get('_regionVisible') !== false &&
this.shouldRenderFeatureByDensity(feature, currentZoom) &&
this.shouldRenderNearbyFeature(feature, currentZoom);
if (!shouldRenderIcon) {
this.syncFeatureIconCollisionVisible(feature, false);
this.syncFeatureLabelCollisionVisible(feature, false);
return;
}
const geometry = feature.getGeometry();
if (!geometry || geometry.getType() !== 'Point') {
this.syncFeatureIconCollisionVisible(feature, false);
this.syncFeatureLabelCollisionVisible(feature, false);
return;
}
const coordinates = (geometry as any).getCoordinates?.();
if (!coordinates) {
this.syncFeatureIconCollisionVisible(feature, false);
this.syncFeatureLabelCollisionVisible(feature, false);
return;
}
const pixel = this.map?.getPixelFromCoordinate(coordinates);
if (!pixel) {
this.syncFeatureIconCollisionVisible(feature, false);
this.syncFeatureLabelCollisionVisible(feature, false);
return;
}
const dynamicScale = this.getDynamicPointScale(currentZoom);
// 备注:图标碰撞盒适当小于视觉图标尺寸,允许相邻站点轻微贴近显示,
// 避免像“小浪底/三门峡”这类近点被过早裁掉成只剩一个点。
const iconCollisionSize = Math.max(12, 16 * dynamicScale);
const iconPadding = 0;
const iconLeft = pixel[0] - iconCollisionSize / 2 - iconPadding;
const iconRight = pixel[0] + iconCollisionSize / 2 + iconPadding;
const iconTop = pixel[1] - iconCollisionSize / 2 - iconPadding;
const iconBottom = pixel[1] + iconCollisionSize / 2 + iconPadding;
const labelText = this.formatPointLabelText(
feature.get('_labelText') as string
);
const fontSize = this.getPointFontSize(dynamicScale);
const lines = labelText ? labelText.split('\n') : [''];
const maxLineLength = Math.max(...lines.map(line => line.length), 1);
const labelLineCount = lines.length;
const labelOffsetY = this.getPointLabelCollisionOffsetY(
dynamicScale,
labelLineCount
);
const estimatedWidth = maxLineLength * fontSize * 0.6 + 16;
const estimatedHeight = labelLineCount * (fontSize + 4) + 8;
const centerX = pixel[0];
const centerY = pixel[1] + labelOffsetY;
const candidateId = String(
feature.getId?.() || feature.get('stcd') || ''
);
const dedupeKey =
candidateId ||
[
feature.get('_layerKey') || '',
pixel[0].toFixed(2),
pixel[1].toFixed(2),
labelText
].join('|');
candidateMap.set(dedupeKey, {
feature,
iconLeft,
iconRight,
iconTop,
iconBottom,
hasLabel: !!labelText,
left: centerX - estimatedWidth / 2,
right: centerX + estimatedWidth / 2,
top: centerY - estimatedHeight / 2,
bottom: centerY + estimatedHeight / 2,
priority: Number(feature.get('_nearbyPriority') || 9999),
densityPriority: this.getFeatureDensityPriority(feature),
id: candidateId,
pixelX: pixel[0],
pixelY: pixel[1]
});
});
candidateMap.forEach(candidate => {
candidates.push(candidate);
});
candidates.sort((left, right) => {
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 placedLabelRects: Array<{
featureId: string;
left: number;
right: number;
top: number;
bottom: number;
}> = [];
let hiddenByIconCollisionCount = 0;
let hiddenByLabelCollisionCount = 0;
let hiddenByIconHiddenCount = 0;
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 = placedIconRects.some(rect =>
this.checkLabelCollision(rect, iconRect)
);
const iconVisible = !iconHasCollision;
this.syncFeatureIconCollisionVisible(candidate.feature, iconVisible);
if (iconVisible) {
placedIconRects.push({
featureId,
left: iconRect.left,
right: iconRect.right,
top: iconRect.top,
bottom: iconRect.bottom
});
}
});
const labelCandidates = [...candidates].sort((left, right) => {
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);
return;
}
if (!iconVisible) {
hiddenByIconHiddenCount += 1;
this.syncFeatureLabelCollisionVisible(candidate.feature, false);
return;
}
const collidingIconRect = placedIconRects.find(rect => {
if (rect.featureId === featureId) {
return false;
}
return this.checkLabelCollisionWithIcon(rect, candidate);
});
const hasCollisionWithIcon = !!collidingIconRect;
const collidingLabelRect = placedLabelRects.find(rect =>
this.checkLabelCollision(rect, candidate)
);
const hasCollisionWithLabel = !!collidingLabelRect;
const visible = !hasCollisionWithIcon && !hasCollisionWithLabel;
if (!visible) {
if (hasCollisionWithIcon) {
hiddenByIconCollisionCount += 1;
} else {
hiddenByLabelCollisionCount += 1;
}
}
this.syncFeatureLabelCollisionVisible(candidate.feature, visible);
if (visible) {
placedLabelRects.push({
featureId,
left: candidate.left,
right: candidate.right,
top: candidate.top,
bottom: candidate.bottom
});
}
});
}
private requestRefreshPointLabelVisibility(immediate = false) {
if (immediate) {
if (this.labelVisibilityRefreshFrameId !== null) {
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
this.labelVisibilityRefreshFrameId = null;
}
this.refreshPointLabelVisibility();
return;
}
if (this.labelVisibilityRefreshFrameId !== null) {
return;
}
this.labelVisibilityRefreshFrameId = window.requestAnimationFrame(() => {
this.labelVisibilityRefreshFrameId = null;
this.refreshPointLabelVisibility();
});
}
private syncFeatureIconCollisionVisible(feature: Feature, visible: boolean) {
if (feature.get('_iconCollisionVisible') !== visible) {
feature.set('_iconCollisionVisible', visible);
feature.changed();
}
}
private syncFeatureLabelCollisionVisible(feature: Feature, visible: boolean) {
if (feature.get('_labelCollisionVisible') !== visible) {
feature.set('_labelCollisionVisible', visible);
feature.changed();
}
}
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.refreshPointLayerStyles();
};
image.onerror = () => {
this.iconLoadState.set(iconUrl, 'error');
this.refreshPointLayerStyles();
};
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;
}
2026-07-01 08:48:39 +08:00
private refreshPointLayerStyles() {
this.pointLayerManager.forEachLayer(layer => {
layer.changed();
2026-04-20 16:58:33 +08:00
});
2026-07-01 08:48:39 +08:00
this.requestRefreshPointLabelVisibility(true);
if (this.isBatchPopupMode) {
this.requestUpdateBatchPopups(true);
}
}
private requestUpdateBatchPopups(immediate = false) {
if (!this.isBatchPopupMode) {
return;
}
if (immediate) {
if (this.batchPopupRefreshFrameId !== null) {
cancelAnimationFrame(this.batchPopupRefreshFrameId);
this.batchPopupRefreshFrameId = null;
}
this.updateBatchPopups();
return;
}
if (this.batchPopupRefreshFrameId !== null) {
return;
}
this.batchPopupRefreshFrameId = window.requestAnimationFrame(() => {
this.batchPopupRefreshFrameId = null;
if (!this.isBatchPopupMode) {
return;
}
this.updateBatchPopups();
});
}
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
};
return !(
narrowedIconRect.right + padding <= labelRect.left ||
narrowedIconRect.left - padding >= labelRect.right ||
narrowedIconRect.bottom + padding <= labelRect.top ||
narrowedIconRect.top - padding >= labelRect.bottom
);
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;
}
/**
*
*
*/
2026-07-01 08:48:39 +08:00
private getPointLabelCollisionOffsetY(
dynamicScale: number,
labelLineCount: number
): number {
return labelLineCount > 1 ? -36 * dynamicScale : -28 * 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;
}
/**
*
* 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(
feature: Feature,
currentZoom: number
): boolean {
2026-07-01 08:48:39 +08:00
const nearbyGroupId = feature.get('_nearbyGroupId');
const groupSize = Number(feature.get('_nearbyGroupSize'));
const priority = Number(feature.get('_nearbyPriority'));
const replaceAtZoom = Number(feature.get('_nearbyReplaceAtZoom'));
const expandAtZoom = Number(feature.get('_nearbyExpandAtZoom'));
const showZoomMin = feature.get('_nearbyShowZoomMin');
const showZoomMax = feature.get('_nearbyShowZoomMax');
if (!nearbyGroupId || !Number.isFinite(groupSize) || groupSize < 2) {
return true;
}
if (!Number.isFinite(priority) || priority <= 0) {
return true;
}
2026-07-01 08:48:39 +08:00
if (
showZoomMin !== null &&
showZoomMin !== undefined &&
currentZoom < Number(showZoomMin)
) {
return false;
}
2026-07-01 08:48:39 +08:00
if (
showZoomMax !== null &&
showZoomMax !== undefined &&
currentZoom > Number(showZoomMax)
) {
return false;
}
if (Number.isFinite(expandAtZoom) && currentZoom >= expandAtZoom) {
return true;
}
if (Number.isFinite(replaceAtZoom) && currentZoom >= replaceAtZoom) {
return priority <= Math.min(2, groupSize);
}
return priority === 1;
}
private createPointStyle1(feature: Feature): Style {
const color = (feature.get('iconCode') as string) || '#3399CC';
const labelText = feature.get('titleName') as string;
const buildType = (feature.get('BuildType') as number) ?? 1;
const currentZoom = this.view ? this.view.getZoom() : 4.5;
let dynamicScale = 0.7 + (currentZoom - 4.5) * 0.08;
dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale));
const fontSize = Math.max(16, Math.min(24, 12 * dynamicScale));
const radius = Math.max(6, Math.min(18, 6 * dynamicScale));
let imageStyle;
if (buildType === 0) {
const canvas = this.createSemiCircleCanvas(radius, color);
imageStyle = new Icon({
img: canvas,
imgSize: [canvas.width, canvas.height],
anchor: [0.5, 0.5]
});
} else {
imageStyle = new Circle({
radius: radius,
fill: new Fill({ color: color }),
stroke: new Stroke({ color: 'rgba(0, 0, 0, 0.5)', width: 1 })
});
}
const styleOptions: any = {
image: imageStyle
};
if (labelText) {
let offsetY = -20 * dynamicScale;
const specialLabels = [
'枕头坝二级',
'老鹰岩二级',
'长洲',
'丹巴',
'玛尔挡',
'班多',
'彭水',
'李家峡',
'ML',
'BDa',
'乌东德',
'里底',
'大华桥',
'巴塘'
];
if (specialLabels.includes(labelText)) {
offsetY = 20 * dynamicScale;
}
styleOptions.text = new Text({
text: labelText,
offsetY: offsetY,
font: `${fontSize}px sans-serif`,
fill: new Fill({ color: '#fff' }),
stroke: new Stroke({ color: 'rgba(0, 0, 0, .9)', width: 2 }),
textAlign: 'center',
declutterMode: 'declutter'
});
}
return new Style(styleOptions);
}
2026-04-20 16:58:33 +08:00
/**
*
* @param layer
*/
addBaseDataLayer(
layer: any,
isShow: boolean,
isCache: boolean = 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;
let 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');
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
}
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;
} else if (layer.type == 'raster-dem') {
const tileLayer = new TileLayer({
source: new XYZ({
url: layer.url,
wrapX: true,
crossOrigin: 'anonymous'
})
});
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;
}
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();
try {
const url =
this.hydropBaseConfig.geojson_url +
`&cql_filter=BASEID='${this.BASEID}'`;
const geoJsonData = await this.loadGeoJsonData(url);
this.currentClipGeoJson = geoJsonData;
this.regionMaskManager.filterPointsByRegion(geoJsonData);
this.regionMaskManager.fitViewToGeoJson(geoJsonData);
this.regionMaskManager.applyMapMaskToLayers(baseLayers, geoJsonData);
} catch (error) {
this.currentClipGeoJson = null;
console.error('加载裁切数据失败:', error);
this.regionMaskManager.clearMapMask();
this.regionMaskManager.showAllPoints();
}
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
}
);
}
/**
* 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 {
2026-07-01 08:48:39 +08:00
if (this.labelVisibilityRefreshFrameId !== null) {
cancelAnimationFrame(this.labelVisibilityRefreshFrameId);
this.labelVisibilityRefreshFrameId = null;
}
2026-04-20 16:58:33 +08:00
this.removeQueryLayer();
this.regionMaskManager.destroy();
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
}
}