2026-06-03 15:03:31 +08:00
|
|
|
import OlMap from 'ol/Map';
|
|
|
|
|
import View from 'ol/View';
|
|
|
|
|
import TileLayer from 'ol/layer/Tile';
|
|
|
|
|
import GeoJSON from 'ol/format/GeoJSON';
|
|
|
|
|
import VectorSource from 'ol/source/Vector';
|
|
|
|
|
import { fromLonLat } from 'ol/proj';
|
|
|
|
|
import { PointLayerManager } from './point-layer-manager';
|
|
|
|
|
|
|
|
|
|
type RegionMaskManagerOptions = {
|
|
|
|
|
map: OlMap | null;
|
|
|
|
|
view: View | null;
|
|
|
|
|
pointLayerManager: PointLayerManager;
|
|
|
|
|
defaultCenter: [number, number];
|
|
|
|
|
defaultZoom: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export class RegionMaskManager {
|
|
|
|
|
private map: OlMap | null;
|
|
|
|
|
private view: View | null;
|
|
|
|
|
private pointLayerManager: PointLayerManager;
|
|
|
|
|
private defaultCenter: [number, number];
|
|
|
|
|
private defaultZoom: number;
|
|
|
|
|
private maskBindings: Array<{
|
|
|
|
|
layer: TileLayer;
|
|
|
|
|
prerender: (event: any) => void;
|
|
|
|
|
postrender: (event: any) => void;
|
|
|
|
|
}> = [];
|
|
|
|
|
|
|
|
|
|
constructor(options: RegionMaskManagerOptions) {
|
|
|
|
|
this.map = options.map;
|
|
|
|
|
this.view = options.view;
|
|
|
|
|
this.pointLayerManager = options.pointLayerManager;
|
|
|
|
|
this.defaultCenter = options.defaultCenter;
|
|
|
|
|
this.defaultZoom = options.defaultZoom;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:同步地图和视图上下文,供地图初始化、销毁和重建后复用区域过滤能力。
|
|
|
|
|
setContext(map: OlMap | null, view: View | null) {
|
|
|
|
|
this.map = map;
|
|
|
|
|
this.view = view;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:统一隐藏所有点位的区域显示状态,供基地裁切前的预处理复用。
|
|
|
|
|
hideAllPoints(): void {
|
|
|
|
|
this.pointLayerManager.forEachFeature(feature => {
|
|
|
|
|
feature.set('_regionVisible', false);
|
|
|
|
|
feature.changed();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:统一恢复所有点位的区域显示状态,供取消裁切或异常兜底时复用。
|
|
|
|
|
showAllPoints(): void {
|
|
|
|
|
this.pointLayerManager.forEachFeature(feature => {
|
|
|
|
|
feature.set('_regionVisible', true);
|
|
|
|
|
feature.changed();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:根据 GeoJSON 边界批量更新点位的区域显隐状态。
|
|
|
|
|
filterPointsByRegion(geoJson: any): void {
|
|
|
|
|
const regionCoords = this.extractPolygonCoords(geoJson);
|
|
|
|
|
if (!regionCoords.length) {
|
|
|
|
|
console.warn('无法解析区域边界,显示所有锚点');
|
|
|
|
|
this.showAllPoints();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.pointLayerManager.forEachFeature(feature => {
|
|
|
|
|
const props = feature.getProperties();
|
|
|
|
|
const lon = Number(props.lgtd);
|
|
|
|
|
const lat = Number(props.lttd);
|
|
|
|
|
|
|
|
|
|
if (!isFinite(lon) || !isFinite(lat)) {
|
|
|
|
|
feature.set('_regionVisible', false);
|
|
|
|
|
feature.changed();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
feature.set('_regionVisible', this.isPointInPolygon(lon, lat, regionCoords));
|
|
|
|
|
feature.changed();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:根据 GeoJSON 范围拟合地图视野,供基地切换后快速聚焦到目标区域。
|
|
|
|
|
fitViewToGeoJson(geoJson: any): void {
|
|
|
|
|
if (!this.map || !this.view) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const features = new GeoJSON().readFeatures(geoJson, {
|
|
|
|
|
dataProjection: 'EPSG:4326',
|
|
|
|
|
featureProjection: 'EPSG:3857'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!features.length) return;
|
|
|
|
|
|
|
|
|
|
const source = new VectorSource({
|
|
|
|
|
features
|
|
|
|
|
});
|
|
|
|
|
const extent = source.getExtent();
|
|
|
|
|
|
|
|
|
|
if (extent[0] === Infinity || extent[0] === -Infinity) {
|
|
|
|
|
console.warn('无法计算有效的地图包围盒');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.view.fit(extent, {
|
|
|
|
|
padding: [100, 200, 50, 50],
|
|
|
|
|
duration: 1000,
|
|
|
|
|
maxZoom: 13
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('调整地图视野失败:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:把 GeoJSON 边界绑定到底图 prerender/postrender 事件,实现区域裁切遮罩。
|
|
|
|
|
applyMapMask(rasterLayer: TileLayer, clipGeoJson: any): void {
|
|
|
|
|
this.applyMapMaskToLayers(rasterLayer ? [rasterLayer] : [], clipGeoJson);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:把 GeoJSON 边界同时绑定到多个底图图层,保证多底图叠加场景下裁切边界保持一致。
|
|
|
|
|
applyMapMaskToLayers(rasterLayers: TileLayer[] = [], clipGeoJson: any): void {
|
|
|
|
|
if (!clipGeoJson) return;
|
|
|
|
|
|
|
|
|
|
const targetLayers = rasterLayers.filter(Boolean);
|
|
|
|
|
if (targetLayers.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const features = new GeoJSON().readFeatures(clipGeoJson, {
|
|
|
|
|
dataProjection: 'EPSG:4326',
|
|
|
|
|
featureProjection: 'EPSG:3857'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!features.length) {
|
|
|
|
|
console.warn('裁切数据为空,无法应用遮罩');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.clearMaskEvents();
|
|
|
|
|
|
|
|
|
|
targetLayers.forEach(rasterLayer => {
|
|
|
|
|
const maskPrerender = (event: any) => {
|
|
|
|
|
const context = event.context;
|
|
|
|
|
const frameState = event.frameState;
|
|
|
|
|
|
|
|
|
|
if (!context || !frameState || !this.map) return;
|
|
|
|
|
|
|
|
|
|
context.save();
|
|
|
|
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
|
|
|
context.beginPath();
|
|
|
|
|
|
|
|
|
|
let hasValidPath = false;
|
|
|
|
|
|
|
|
|
|
for (const feature of features) {
|
|
|
|
|
const geometry = feature.getGeometry();
|
|
|
|
|
if (!geometry) continue;
|
|
|
|
|
|
|
|
|
|
if (geometry.getType() === 'Polygon') {
|
|
|
|
|
const rings = (geometry as any).getCoordinates() as number[][][];
|
|
|
|
|
for (const ring of rings) {
|
|
|
|
|
if (this.drawRingToContext(context, ring, frameState)) {
|
|
|
|
|
hasValidPath = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (geometry.getType() === 'MultiPolygon') {
|
|
|
|
|
const polygons = (geometry as any).getCoordinates() as number[][][][];
|
|
|
|
|
for (const polygonRings of polygons) {
|
|
|
|
|
for (const ring of polygonRings) {
|
|
|
|
|
if (this.drawRingToContext(context, ring, frameState)) {
|
|
|
|
|
hasValidPath = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!hasValidPath) {
|
|
|
|
|
console.warn('未能生成有效的裁切路径');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
context.save();
|
|
|
|
|
context.lineWidth = 6;
|
|
|
|
|
context.strokeStyle = '#6D64DF';
|
|
|
|
|
context.stroke();
|
|
|
|
|
context.clip();
|
|
|
|
|
context.strokeStyle = '#CCC9F4';
|
|
|
|
|
context.lineWidth = 5;
|
|
|
|
|
context.stroke();
|
|
|
|
|
context.clip();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const maskPostrender = (event: any) => {
|
|
|
|
|
if (event.context) {
|
|
|
|
|
event.context.restore();
|
|
|
|
|
event.context.restore();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.maskBindings.push({
|
|
|
|
|
layer: rasterLayer,
|
|
|
|
|
prerender: maskPrerender,
|
|
|
|
|
postrender: maskPostrender
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
rasterLayer.on('prerender', maskPrerender);
|
|
|
|
|
rasterLayer.on('postrender', maskPostrender);
|
|
|
|
|
rasterLayer.changed();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:清除当前底图遮罩并按需恢复默认视野,供切换基地和异常兜底复用。
|
2026-07-14 16:08:10 +08:00
|
|
|
clearMapMask(resetView = true): void {
|
2026-06-03 15:03:31 +08:00
|
|
|
const maskedLayers = this.maskBindings.map(binding => binding.layer);
|
|
|
|
|
this.clearMaskEvents();
|
|
|
|
|
|
|
|
|
|
maskedLayers.forEach(layer => layer.changed());
|
|
|
|
|
|
|
|
|
|
if (resetView && this.view) {
|
|
|
|
|
this.view.animate({
|
|
|
|
|
center: fromLonLat(this.defaultCenter),
|
|
|
|
|
zoom: this.defaultZoom,
|
|
|
|
|
duration: 1000
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:统一销毁区域过滤相关引用和事件,供地图销毁阶段复用。
|
|
|
|
|
destroy(): void {
|
|
|
|
|
this.clearMapMask(false);
|
|
|
|
|
this.map = null;
|
|
|
|
|
this.view = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:从 GeoJSON 中提取可用于点在面判断的多边形环坐标。
|
|
|
|
|
private extractPolygonCoords(geoJson: any): number[][][] {
|
|
|
|
|
if (!geoJson?.features?.length) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const coords: number[][][] = [];
|
|
|
|
|
geoJson.features.forEach((feature: any) => {
|
|
|
|
|
const geometry = feature.geometry;
|
|
|
|
|
if (!geometry) return;
|
|
|
|
|
|
|
|
|
|
if (geometry.type === 'Polygon') {
|
|
|
|
|
coords.push(...geometry.coordinates);
|
|
|
|
|
} else if (geometry.type === 'MultiPolygon') {
|
|
|
|
|
geometry.coordinates.forEach((polygon: number[][][]) => {
|
|
|
|
|
coords.push(...polygon);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return coords;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:使用射线法判断点是否落在任意一个区域环内。
|
|
|
|
|
private isPointInPolygon(
|
|
|
|
|
lon: number,
|
|
|
|
|
lat: number,
|
|
|
|
|
polygons: number[][][]
|
|
|
|
|
): boolean {
|
|
|
|
|
for (const polygon of polygons) {
|
|
|
|
|
if (this.isPointInRing(lon, lat, polygon)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:使用射线法判断点是否落在单个闭合环内。
|
|
|
|
|
private isPointInRing(lon: number, lat: number, ring: number[][]): boolean {
|
|
|
|
|
let inside = false;
|
|
|
|
|
const ringLength = ring.length;
|
|
|
|
|
|
|
|
|
|
for (let i = 0, j = ringLength - 1; i < ringLength; j = i++) {
|
|
|
|
|
const xi = ring[i][0];
|
|
|
|
|
const yi = ring[i][1];
|
|
|
|
|
const xj = ring[j][0];
|
|
|
|
|
const yj = ring[j][1];
|
|
|
|
|
|
|
|
|
|
const intersect =
|
|
|
|
|
yi > lat !== yj > lat &&
|
|
|
|
|
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
|
|
|
|
|
|
|
|
|
|
if (intersect) {
|
|
|
|
|
inside = !inside;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return inside;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:把单个面环转换为当前帧 Canvas 可裁切的路径。
|
|
|
|
|
private drawRingToContext(
|
|
|
|
|
context: CanvasRenderingContext2D,
|
|
|
|
|
ring: number[][],
|
|
|
|
|
frameState: any
|
|
|
|
|
): boolean {
|
|
|
|
|
if (!ring || ring.length < 3) return false;
|
|
|
|
|
|
|
|
|
|
const pixelRatio = frameState.pixelRatio || window.devicePixelRatio || 1;
|
|
|
|
|
let moved = false;
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < ring.length; i++) {
|
|
|
|
|
const coord = ring[i];
|
|
|
|
|
if (!coord || coord.length < 2 || typeof coord[0] !== 'number') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cssPixel = this.map?.getPixelFromCoordinate(coord as [number, number]);
|
|
|
|
|
if (!cssPixel) continue;
|
|
|
|
|
|
|
|
|
|
const canvasX = cssPixel[0] * pixelRatio;
|
|
|
|
|
const canvasY = cssPixel[1] * pixelRatio;
|
|
|
|
|
|
|
|
|
|
if (!moved) {
|
|
|
|
|
context.moveTo(canvasX, canvasY);
|
|
|
|
|
moved = true;
|
|
|
|
|
} else {
|
|
|
|
|
context.lineTo(canvasX, canvasY);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (moved) {
|
|
|
|
|
context.closePath();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 备注:解除旧底图上的遮罩事件,避免重复绑定或残留旧裁切效果。
|
|
|
|
|
private clearMaskEvents(): void {
|
|
|
|
|
this.maskBindings.forEach(binding => {
|
|
|
|
|
binding.layer.un('prerender', binding.prerender);
|
|
|
|
|
binding.layer.un('postrender', binding.postrender);
|
|
|
|
|
});
|
|
|
|
|
this.maskBindings = [];
|
|
|
|
|
}
|
|
|
|
|
}
|