|
|
@@ -0,0 +1,148 @@
|
|
|
+import { normalizeCoord } from "@/apis/park";
|
|
|
+import type { EntType } from "@/apis/types";
|
|
|
+import { API_BASE_URL } from "@/constant";
|
|
|
+import type { HotsoptData, HotsoptItem } from "@/storeDB/hotsopt";
|
|
|
+import { Cartesian3, Cartographic, type Viewer } from "cesium";
|
|
|
+
|
|
|
+const DEFAULT_SITE_ID = 1;
|
|
|
+const SAMPLE_START_HEIGHT = 1000;
|
|
|
+/** 两点水平距离小于该值(米)则聚为一组 */
|
|
|
+const CLUSTER_DISTANCE_METERS = 10;
|
|
|
+
|
|
|
+type EntWithCoord = EntType & { lon: number; lat: number };
|
|
|
+
|
|
|
+const toRad = (deg: number) => (deg * Math.PI) / 180;
|
|
|
+
|
|
|
+/** 两点大致水平距离(米) */
|
|
|
+const haversineMeters = (
|
|
|
+ lon1: number,
|
|
|
+ lat1: number,
|
|
|
+ lon2: number,
|
|
|
+ lat2: number,
|
|
|
+) => {
|
|
|
+ const R = 6371000;
|
|
|
+ const dLat = toRad(lat2 - lat1);
|
|
|
+ const dLon = toRad(lon2 - lon1);
|
|
|
+ const a =
|
|
|
+ Math.sin(dLat / 2) ** 2 +
|
|
|
+ Math.cos(toRad(lat1)) *
|
|
|
+ Math.cos(toRad(lat2)) *
|
|
|
+ Math.sin(dLon / 2) ** 2;
|
|
|
+ return 2 * R * Math.asin(Math.sqrt(a));
|
|
|
+};
|
|
|
+
|
|
|
+/** 按距离聚类:落入阈值内的企业并入最近一组 */
|
|
|
+const clusterByDistance = (ents: EntWithCoord[]): EntWithCoord[][] => {
|
|
|
+ const groups: { lon: number; lat: number; items: EntWithCoord[] }[] = [];
|
|
|
+
|
|
|
+ for (const ent of ents) {
|
|
|
+ let best: (typeof groups)[number] | undefined;
|
|
|
+ let bestDist = Infinity;
|
|
|
+
|
|
|
+ for (const group of groups) {
|
|
|
+ const dist = haversineMeters(ent.lon, ent.lat, group.lon, group.lat);
|
|
|
+ if (dist <= CLUSTER_DISTANCE_METERS && dist < bestDist) {
|
|
|
+ best = group;
|
|
|
+ bestDist = dist;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (best) {
|
|
|
+ best.items.push(ent);
|
|
|
+ const n = best.items.length;
|
|
|
+ best.lon = best.items.reduce((sum, e) => sum + e.lon, 0) / n;
|
|
|
+ best.lat = best.items.reduce((sum, e) => sum + e.lat, 0) / n;
|
|
|
+ } else {
|
|
|
+ groups.push({ lon: ent.lon, lat: ent.lat, items: [ent] });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return groups.map((g) => g.items);
|
|
|
+};
|
|
|
+
|
|
|
+/** 解析企业经纬度:兼容字符串、别名字段,以及 lon/lat 写反 */
|
|
|
+const resolveEntCoord = (ent: EntType): EntWithCoord | null => {
|
|
|
+ const raw = normalizeCoord(ent as EntType & Record<string, unknown>);
|
|
|
+ let { lon, lat } = raw;
|
|
|
+
|
|
|
+ if (
|
|
|
+ Number.isFinite(lon) &&
|
|
|
+ Number.isFinite(lat) &&
|
|
|
+ Math.abs(lat) > 90 &&
|
|
|
+ Math.abs(lon) <= 90
|
|
|
+ ) {
|
|
|
+ [lon, lat] = [lat, lon];
|
|
|
+ }
|
|
|
+
|
|
|
+ if (
|
|
|
+ !Number.isFinite(lon) ||
|
|
|
+ !Number.isFinite(lat) ||
|
|
|
+ Math.abs(lon) > 180 ||
|
|
|
+ Math.abs(lat) > 90
|
|
|
+ ) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return { ...ent, lon, lat };
|
|
|
+};
|
|
|
+
|
|
|
+const toHotspotItem = (ent: EntWithCoord): HotsoptItem => ({
|
|
|
+ id: ent.id,
|
|
|
+ content: [
|
|
|
+ API_BASE_URL + (ent.thumb || ""),
|
|
|
+ ent.name,
|
|
|
+ ent.type || ent.tagRyLabel || "",
|
|
|
+ ],
|
|
|
+});
|
|
|
+
|
|
|
+/** 按距离聚合企业,采样模型高度后转为 ECEF 热点 */
|
|
|
+export const sampleEntPositions = async (
|
|
|
+ viewer: Viewer,
|
|
|
+ ents: EntType[],
|
|
|
+): Promise<HotsoptData[]> => {
|
|
|
+ const withCoord = ents
|
|
|
+ .map(resolveEntCoord)
|
|
|
+ .filter((e): e is EntWithCoord => e != null);
|
|
|
+
|
|
|
+ if (withCoord.length === 0) return [];
|
|
|
+
|
|
|
+ const groupList = clusterByDistance(withCoord);
|
|
|
+ const cartographics = groupList.map((list) => {
|
|
|
+ const lon = list.reduce((sum, e) => sum + e.lon, 0) / list.length;
|
|
|
+ const lat = list.reduce((sum, e) => sum + e.lat, 0) / list.length;
|
|
|
+ return Cartographic.fromDegrees(lon, lat, SAMPLE_START_HEIGHT);
|
|
|
+ });
|
|
|
+
|
|
|
+ let sampled: Cartographic[] = cartographics;
|
|
|
+ try {
|
|
|
+ if (!viewer.isDestroyed()) {
|
|
|
+ sampled = await viewer.scene.sampleHeightMostDetailed(cartographics);
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ sampled = cartographics;
|
|
|
+ }
|
|
|
+
|
|
|
+ return groupList.map((list, i) => {
|
|
|
+ const carto = sampled[i] ?? cartographics[i];
|
|
|
+ const height =
|
|
|
+ carto.height != null && Number.isFinite(carto.height) ? carto.height : 0;
|
|
|
+ const lon = list.reduce((sum, e) => sum + e.lon, 0) / list.length;
|
|
|
+ const lat = list.reduce((sum, e) => sum + e.lat, 0) / list.length;
|
|
|
+ const cartesian = Cartesian3.fromDegrees(lon, lat, height);
|
|
|
+ const items = list.map(toHotspotItem);
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: list[0].id,
|
|
|
+ siteId: DEFAULT_SITE_ID,
|
|
|
+ position: {
|
|
|
+ x: cartesian.x,
|
|
|
+ y: cartesian.y,
|
|
|
+ z: cartesian.z,
|
|
|
+ },
|
|
|
+ num: items.length,
|
|
|
+ className: "hotspot-overview",
|
|
|
+ content: items[0].content,
|
|
|
+ items,
|
|
|
+ } satisfies HotsoptData;
|
|
|
+ });
|
|
|
+};
|