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; 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) => { 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 => { return ( point.titleName || point.stnm || point.ennm || point.stcd || point._id || '' ); }; const getPointStableIdentity = ( point: Record, 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[] = [] ): 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(); 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(); 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[] = [], rule: NearbyPointAutoRule = getNearbyPointAutoRule() ): Record[] => { 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; };