WholeProcessPlatform/frontend/src/modules/map/domain/nearby-point-rules.ts

397 lines
11 KiB
TypeScript
Raw Normal View History

2026-07-01 08:48:39 +08:00
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: 6.1
},
{
minDensityValue: 400000,
minZoom: 8.1
},
{
minDensityValue: 50000,
minZoom: 10.6
},
{
minDensityValue: 0,
minZoom: 12.2
}
];
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 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 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 => {
const displayMode: NearbyPointDisplayMode =
priority === 1 ? 'default' : priority === 2 ? 'replace' : 'expand';
return {
groupId,
displayName,
groupSize,
priority,
showZoomMin: priority === 1 ? null : rule.replaceAtZoom,
showZoomMax: null,
replaceAtZoom: rule.replaceAtZoom,
expandAtZoom: rule.expandAtZoom,
expandOffsetPx: rule.expandOffsetPx,
expandAngleDeg: priority === 1 ? 0 : (priority - 2) * rule.angleStepDeg,
displayMode
};
};
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 buildLayerScopedNearbyGroups = (
candidates: GroupCandidate[],
rule: NearbyPointAutoRule
): GroupCandidate[][] => {
const layerScopedCandidates = new Map<string, GroupCandidate[]>();
candidates.forEach(candidate => {
const layerKey = candidate.layerKey || '__empty_layer__';
const layerItems = layerScopedCandidates.get(layerKey) || [];
layerItems.push(candidate);
layerScopedCandidates.set(layerKey, layerItems);
});
const result: GroupCandidate[][] = [];
layerScopedCandidates.forEach(layerItems => {
const sortedCandidates = [...layerItems].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 = buildLayerScopedNearbyGroups(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;
};