WholeProcessPlatform/frontend/src/modules/map/domain/nearby-point-rules.ts
2026-07-14 16:09:01 +08:00

419 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

export type NearbyPointDisplayMode = 'default' | 'replace' | 'expand';
export type NearbyPointAutoRule = {
distanceThresholdMeters: number;
replaceAtZoom: number;
expandAtZoom: number;
expandOffsetPx: number;
angleStepDeg: number;
minGroupSize: number;
};
export type NearbyPointLayoutRule = {
ringSize: number;
fanAngleForTwoDeg: number;
fanAngleForFourDeg: number;
fanAngleForManyDeg: number;
ringGapPx: number;
ringGapFactor: number;
};
export type NearbyPointDensityDisplayRule = {
minDensityValue: number;
minZoom: number;
};
export type NearbyPointConfig = {
autoRule: NearbyPointAutoRule;
layoutRule: NearbyPointLayoutRule;
densityDisplayRules: NearbyPointDensityDisplayRule[];
};
export type NearbyPointRuntimeMeta = {
groupId: string;
displayName: string;
groupSize: number;
priority: number;
showZoomMin: number | null;
showZoomMax: number | null;
replaceAtZoom: number;
expandAtZoom: number;
expandOffsetPx: number;
expandAngleDeg: number;
displayMode: NearbyPointDisplayMode;
};
// 备注:近邻点统一调参入口。
// 当前参数偏向“更容易成组、稍早进入替换与展开、展开位移更明显”,
// 方便抽吸效果在常用浏览层级更早被感知。
export const DEFAULT_NEARBY_POINT_AUTO_RULE: NearbyPointAutoRule = {
distanceThresholdMeters: 9000,
replaceAtZoom: 10.5,
expandAtZoom: 12.2,
expandOffsetPx: 48,
angleStepDeg: 55,
minGroupSize: 2
};
// 备注:展开布局统一调参入口。这里控制单圈数量、扇形角度和外圈扩散节奏。
export const DEFAULT_NEARBY_POINT_LAYOUT_RULE: NearbyPointLayoutRule = {
ringSize: 5,
fanAngleForTwoDeg: 110,
fanAngleForFourDeg: 150,
fanAngleForManyDeg: 180,
ringGapPx: 22,
ringGapFactor: 0.85
};
// 备注密度分档显示规则。distance 越大表示越稀疏,越早参与显示候选。
export const DEFAULT_NEARBY_POINT_DENSITY_DISPLAY_RULES: NearbyPointDensityDisplayRule[] =
[
{
minDensityValue: 1500000,
minZoom: 0
},
{
minDensityValue: 800000,
minZoom: 0
},
{
minDensityValue: 400000,
minZoom: 0
},
{
minDensityValue: 50000,
minZoom: 2.0
},
{
minDensityValue: 0,
minZoom: 4.0
}
];
export const DEFAULT_NEARBY_POINT_CONFIG: NearbyPointConfig = {
autoRule: DEFAULT_NEARBY_POINT_AUTO_RULE,
layoutRule: DEFAULT_NEARBY_POINT_LAYOUT_RULE,
densityDisplayRules: DEFAULT_NEARBY_POINT_DENSITY_DISPLAY_RULES
};
export const getNearbyPointConfig = (): NearbyPointConfig => {
return DEFAULT_NEARBY_POINT_CONFIG;
};
export const getNearbyPointAutoRule = (): NearbyPointAutoRule => {
return getNearbyPointConfig().autoRule;
};
export const getNearbyPointLayoutRule = (): NearbyPointLayoutRule => {
return getNearbyPointConfig().layoutRule;
};
export const getNearbyPointDensityDisplayRules =
(): NearbyPointDensityDisplayRule[] => {
return getNearbyPointConfig().densityDisplayRules;
};
type GroupCandidate = {
index: number;
point: Record<string, any>;
lon: number;
lat: number;
layerKey: string;
};
const isAlarmRangeLegendState = (legendState: string): boolean => {
return (
legendState.startsWith('alarm_range_') ||
legendState.startsWith('large_eng_built_alarm_range_') ||
legendState.startsWith('mid_eng_built_alarm_range_')
);
};
const getPointRenderPriority = (point: Record<string, any>): number => {
const layerKey = String(point.layerKey || point.type || '').toLowerCase();
const legendState = String(point.anchoPointState || '').trim().toLowerCase();
if (
layerKey.includes('eng_alarm_point') ||
layerKey.includes('alarm_range') ||
isAlarmRangeLegendState(legendState)
) {
return 300;
}
if (
layerKey.includes('eng_point') ||
legendState.startsWith('large_eng_') ||
legendState.startsWith('mid_eng_')
) {
return 200;
}
return 100;
};
const normalizeText = (value: unknown): string => {
return String(value ?? '')
.trim()
.replace(/[()]/g, '')
.toLowerCase();
};
const toFiniteNumber = (value: unknown): number | null => {
if (value === '' || value === null || value === undefined) {
return null;
}
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
};
const haversineDistanceMeters = (
lon1: number,
lat1: number,
lon2: number,
lat2: number
): number => {
const toRad = (degree: number) => (degree * Math.PI) / 180;
const earthRadius = 6371000;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const lat1Rad = toRad(lat1);
const lat2Rad = toRad(lat2);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1Rad) *
Math.cos(lat2Rad) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadius * c;
};
const clearNearbyPointRuntimeMeta = (point: Record<string, any>) => {
delete point._nearbyRule;
delete point._nearbyGroupId;
delete point._nearbyDisplayName;
delete point._nearbyGroupSize;
delete point._nearbyPriority;
delete point._nearbyShowZoomMin;
delete point._nearbyShowZoomMax;
delete point._nearbyReplaceAtZoom;
delete point._nearbyExpandAtZoom;
delete point._nearbyExpandOffsetPx;
delete point._nearbyExpandAngleDeg;
delete point._nearbyDisplayMode;
delete point._nearbyIsPrimary;
};
const getPointDisplayName = (point: Record<string, any>): string => {
return (
point.titleName || point.stnm || point.ennm || point.stcd || point._id || ''
);
};
const getPointStableIdentity = (
point: Record<string, any>,
index: number
): string => {
return normalizeText(
point.stcd ||
point._id ||
point.titleName ||
point.stnm ||
point.ennm ||
`point_${index}`
);
};
const getGroupSortKey = (candidate: GroupCandidate): string => {
return [
getPointStableIdentity(candidate.point, candidate.index),
normalizeText(candidate.point.layerKey),
String(candidate.index).padStart(6, '0')
].join('|');
};
const compareGroupCandidates = (
left: GroupCandidate,
right: GroupCandidate
): number => {
const leftRenderPriority = getPointRenderPriority(left.point);
const rightRenderPriority = getPointRenderPriority(right.point);
if (leftRenderPriority !== rightRenderPriority) {
return rightRenderPriority - leftRenderPriority;
}
const leftDensityValue = toFiniteNumber(left.point.distance);
const rightDensityValue = toFiniteNumber(right.point.distance);
if (
leftDensityValue !== null &&
rightDensityValue !== null &&
leftDensityValue !== rightDensityValue
) {
return rightDensityValue - leftDensityValue;
}
if (leftDensityValue !== null || rightDensityValue !== null) {
return leftDensityValue !== null ? -1 : 1;
}
return getGroupSortKey(left).localeCompare(getGroupSortKey(right));
};
const buildRuntimeMeta = (
groupId: string,
groupSize: number,
priority: number,
displayName: string,
rule: NearbyPointAutoRule
): NearbyPointRuntimeMeta => {
return {
groupId,
displayName,
groupSize,
priority,
showZoomMin: null,
showZoomMax: null,
replaceAtZoom: rule.replaceAtZoom,
expandAtZoom: rule.expandAtZoom,
expandOffsetPx: rule.expandOffsetPx,
expandAngleDeg: 0,
displayMode: 'default'
};
};
const buildGroupCandidates = (
points: Record<string, any>[] = []
): GroupCandidate[] => {
const candidates: GroupCandidate[] = [];
points.forEach((point, index) => {
const lon = toFiniteNumber(point.lgtd);
const lat = toFiniteNumber(point.lttd);
if (lon === null || lat === null) {
clearNearbyPointRuntimeMeta(point);
return;
}
if (Math.abs(lon) > 180 || Math.abs(lat) > 90) {
clearNearbyPointRuntimeMeta(point);
return;
}
candidates.push({
index,
point,
lon,
lat,
layerKey: String(point.layerKey || '')
});
});
return candidates;
};
const buildNearbyGroups = (
candidates: GroupCandidate[],
rule: NearbyPointAutoRule
): GroupCandidate[][] => {
const result: GroupCandidate[][] = [];
const sortedCandidates = [...candidates].sort(compareGroupCandidates);
const consumedIndexes = new Set<number>();
sortedCandidates.forEach((seedCandidate, seedIndex) => {
if (consumedIndexes.has(seedIndex)) {
return;
}
const groupItems: GroupCandidate[] = [seedCandidate];
const groupIndexes = [seedIndex];
for (
let candidateIndex = seedIndex + 1;
candidateIndex < sortedCandidates.length;
candidateIndex += 1
) {
if (consumedIndexes.has(candidateIndex)) {
continue;
}
const currentCandidate = sortedCandidates[candidateIndex];
const distanceMeters = haversineDistanceMeters(
seedCandidate.lon,
seedCandidate.lat,
currentCandidate.lon,
currentCandidate.lat
);
if (distanceMeters <= rule.distanceThresholdMeters) {
groupItems.push(currentCandidate);
groupIndexes.push(candidateIndex);
}
}
if (groupItems.length >= rule.minGroupSize) {
groupIndexes.forEach(index => consumedIndexes.add(index));
result.push(groupItems);
}
});
return result;
};
// 备注:统一按空间距离自动识别近邻点组,不按名称或点类型分规则。
export const attachNearbyPointRuntimeMeta = (
points: Record<string, any>[] = [],
rule: NearbyPointAutoRule = getNearbyPointAutoRule()
): Record<string, any>[] => {
if (!Array.isArray(points) || points.length === 0) {
return [];
}
points.forEach(point => clearNearbyPointRuntimeMeta(point));
const candidates = buildGroupCandidates(points);
if (candidates.length < rule.minGroupSize) {
return points;
}
const groupedCandidates = buildNearbyGroups(candidates, rule);
let groupSeed = 0;
groupedCandidates.forEach(groupItems => {
if (groupItems.length < rule.minGroupSize) {
return;
}
const sortedItems = [...groupItems].sort(compareGroupCandidates);
groupSeed += 1;
const groupId = `nearby-group-${groupSeed}`;
sortedItems.forEach((candidate, orderIndex) => {
const priority = orderIndex + 1;
const runtimeMeta = buildRuntimeMeta(
groupId,
sortedItems.length,
priority,
getPointDisplayName(candidate.point),
rule
);
candidate.point._nearbyRule = runtimeMeta;
candidate.point._nearbyGroupId = runtimeMeta.groupId;
candidate.point._nearbyDisplayName = runtimeMeta.displayName;
candidate.point._nearbyGroupSize = runtimeMeta.groupSize;
candidate.point._nearbyPriority = runtimeMeta.priority;
candidate.point._nearbyShowZoomMin = runtimeMeta.showZoomMin;
candidate.point._nearbyShowZoomMax = runtimeMeta.showZoomMax;
candidate.point._nearbyReplaceAtZoom = runtimeMeta.replaceAtZoom;
candidate.point._nearbyExpandAtZoom = runtimeMeta.expandAtZoom;
candidate.point._nearbyExpandOffsetPx = runtimeMeta.expandOffsetPx;
candidate.point._nearbyExpandAngleDeg = runtimeMeta.expandAngleDeg;
candidate.point._nearbyDisplayMode = runtimeMeta.displayMode;
candidate.point._nearbyIsPrimary = runtimeMeta.priority === 1;
});
});
return points;
};