419 lines
11 KiB
TypeScript
419 lines
11 KiB
TypeScript
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;
|
||
};
|