diff --git a/src/assets/mapUtils/commonFun.ts b/src/assets/mapUtils/commonFun.ts
index 2219ea3..3c42a99 100644
--- a/src/assets/mapUtils/commonFun.ts
+++ b/src/assets/mapUtils/commonFun.ts
@@ -122,3 +122,330 @@ export function getCenter(list = []) {
let centerObj = turf.center(features);
return centerObj?.geometry?.coordinates;
}
+
+/**
+ * @desc 球面距离(haversine),返回米
+ * @param lng1 经度1
+ * @param lat1 纬度1
+ * @param lng2 经度2
+ * @param lat2 纬度2
+ */
+export function haversine(lng1: number, lat1: number, lng2: number, lat2: number): number {
+ const toRad = (d: number) => (d * Math.PI) / 180;
+ const R = 6371008.8; // 地球平均半径,单位米
+ const dLat = toRad(lat2 - lat1);
+ const dLng = toRad(lng2 - lng1);
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return R * c;
+}
+
+/**
+ * @desc 单链聚合:距离阈值(米)内的点归为同一簇;簇内不足2点返回 null
+ * @param items 待聚合的点位(必须含 lon/lat 经纬度)
+ * @param radiusMeters 聚合半径,单位米,默认 30
+ */
+export function clusterByRadius(items: any[] = [], radiusMeters = 30): any[][] {
+ const clusters: any[][] = [];
+ const visited = new Array(items.length).fill(false);
+ for (let i = 0; i < items.length; i++) {
+ if (visited[i]) continue;
+ const seed = items[i];
+ const seedLng = Number(seed.lon ?? seed.longitude ?? seed.lng);
+ const seedLat = Number(seed.lat ?? seed.latitude);
+ if (!seedLng || !seedLat) continue;
+ const cluster: any[] = [seed];
+ visited[i] = true;
+ for (let j = i + 1; j < items.length; j++) {
+ if (visited[j]) continue;
+ const cur = items[j];
+ const curLng = Number(cur.lon ?? cur.longitude ?? cur.lng);
+ const curLat = Number(cur.lat ?? cur.latitude);
+ if (!curLng || !curLat) continue;
+ const d = haversine(seedLng, seedLat, curLng, curLat);
+ if (d <= radiusMeters) {
+ cluster.push(cur);
+ visited[j] = true;
+ }
+ }
+ // 不足 2 个点不形成聚合(保留为独立 marker)
+ if (cluster.length >= 2) {
+ clusters.push(cluster);
+ }
+ }
+ return clusters;
+}
+
+/**
+ * @desc 计算簇的几何中心(取所有点的经纬度平均)
+ */
+export function getClusterCenter(cluster: any[] = []): [number, number] {
+ if (!cluster.length) return [0, 0];
+ let sumLng = 0;
+ let sumLat = 0;
+ let count = 0;
+ for (const item of cluster) {
+ const lng = Number(item.lon ?? item.longitude ?? item.lng);
+ const lat = Number(item.lat ?? item.latitude);
+ if (!lng || !lat) continue;
+ sumLng += lng;
+ sumLat += lat;
+ count++;
+ }
+ if (!count) return [0, 0];
+ return [sumLng / count, sumLat / count];
+}
+
+/**
+ * @desc 按数量返回聚合徽标的颜色
+ * @param count 簇内点数
+ */
+export function getClusterColor(count: number): { bg: string; border: string } {
+ if (count >= 10) return { bg: '#E74C3C', border: '#B83A2D' };
+ if (count >= 5) return { bg: '#F5A623', border: '#C77F12' };
+ return { bg: '#3FA9F5', border: '#2380C7' };
+}
+
+/**
+ * @desc 生成聚合 marker 的 HTML 内容(纯色圆形 + 数量 N + 左上角位置编号角标)
+ * @param count 簇内点数(主圈中心数字)
+ * @param items 簇内所有点位,用于挑最近资源并生成 title 提示
+ * @param userLocation [lng, lat] 用户位置,用于挑最近资源
+ * @param clusterNumber 聚合点本身的位置编号(按距离用户升序),左上角小角标显示;不传则不显示
+ */
+export function buildClusterIcon(
+ count: number,
+ items: any[] = [],
+ userLocation?: [number, number],
+ clusterNumber?: number
+): string {
+ // 挑选最近的资源(仅用于 title 提示)
+ let nearest: any = items[0] || {};
+ if (userLocation && items.length > 1) {
+ let minDist = Infinity;
+ for (const it of items) {
+ const lng = Number(it.lon ?? it.longitude ?? it.lng);
+ const lat = Number(it.lat ?? it.latitude);
+ if (!lng || !lat) continue;
+ const d = haversine(userLocation[0], userLocation[1], lng, lat);
+ if (d < minDist) {
+ minDist = d;
+ nearest = it;
+ }
+ }
+ }
+ const { bg, border } = getClusterColor(count);
+ const nearestName = nearest.name || typeToLabel(nearest.type);
+ const tip = `附近 ${count} 个资源,最近:${nearestName}`;
+
+ // 左上角位置编号角标(仅当传了 clusterNumber 才显示)
+ const badgeHtml =
+ clusterNumber !== undefined && clusterNumber !== null
+ ? `
${clusterNumber}
`
+ : '';
+
+ // 主圈:纯色圆 + 数量 N 在中心
+ return `
+
+
${count}
+ ${badgeHtml}
+
`;
+}
+
+/**
+ * @desc 异步创建带数字角标的图标:图片加载完成后绘制到 canvas,再叠加红色数字角标
+ * @param originalIconUrl 原始图标 url(来自 typeToIcon)
+ * @param text 角标文字(数字 1/2/3/...)
+ * @param type 类型:1 医院 / 3 健康小屋 / 4 AED / 5 救护车,影响 canvas 尺寸
+ */
+export function createNumberedMarkerIcon(originalIconUrl: string, text: string | number, type: string | number): Promise {
+ return new Promise((resolve) => {
+ // 异常兜底:url 为空直接返回空串,让调用方决定
+ if (!originalIconUrl) return resolve('');
+ // 兜底:不在浏览器环境(SSR / 测试),返回原 url
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
+ return resolve(originalIconUrl);
+ }
+ const width = type == 1 ? 40 : 28;
+ const height = type == 1 ? 69 : 49;
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height + 15;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return resolve(originalIconUrl);
+ const img = new Image();
+ // 不设置 crossOrigin:Vite 的本地资源是 blob URL,无需 CORS;
+ // 设置 crossOrigin=anonymous 在某些环境下反而触发 onerror。
+ img.onload = () => {
+ try {
+ ctx.drawImage(img, 0, 10, width, height);
+ const textX = type == 1 ? 32 : 22;
+ const textY = 11;
+ const textStr = String(text);
+ const textWidth = ctx.measureText(textStr).width;
+ const circleRadius = Math.max(textWidth / 2 + 1, 6);
+ ctx.beginPath();
+ ctx.arc(textX, textY, circleRadius, 0, Math.PI * 2);
+ ctx.fillStyle = 'red';
+ ctx.fill();
+ ctx.font = 'bold 10px Arial';
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#ffffff';
+ ctx.fillText(textStr, type == 1 ? 35 : 24.5, 10.8);
+ resolve(canvas.toDataURL());
+ } catch (e) {
+ // canvas 污染等异常:降级到原 url
+ resolve(originalIconUrl);
+ }
+ };
+ img.onerror = () => resolve(originalIconUrl);
+ img.src = originalIconUrl;
+ // 兜底:5 秒未触发 onload/onerror 也降级
+ setTimeout(() => resolve(originalIconUrl), 5000);
+ });
+}
+
+/**
+ * @desc 类型编号 → 中文标签(用于簇列表)
+ * @param type 1 医院 / 3 健康小屋 / 4 AED / 5 救护车
+ */
+export function typeToLabel(type: string | number): string {
+ const map: Record = {
+ '1': '医院',
+ '3': '健康小屋',
+ '4': 'AED',
+ '5': '救护车',
+ };
+ return map[String(type)] || '资源';
+}
+
+/**
+ * @desc 生成簇列表 InfoWindow 的 HTML 内容
+ * @param items 簇内点位
+ * @param userLocation [lng, lat] 用户位置,用于计算直线距离并排序
+ */
+export function buildClusterInfoContent(items: any[] = [], userLocation?: [number, number]): string {
+ // 计算直线距离并按距离升序
+ const list = items.map((it) => {
+ const lng = Number(it.lon ?? it.longitude ?? it.lng);
+ const lat = Number(it.lat ?? it.latitude);
+ let distance = Number(it.distance);
+ if (!distance && userLocation) {
+ distance = haversine(userLocation[0], userLocation[1], lng, lat);
+ }
+ return { ...it, _distance: distance || 0, _lng: lng, _lat: lat };
+ });
+ list.sort((a, b) => a._distance - b._distance);
+
+ const formatDist = (m: number) => (m >= 1000 ? `${(m / 1000).toFixed(2)} 公里` : `${Math.round(m)} 米`);
+
+ const rows = list
+ .map(
+ (it, idx) => `
+
+
+
+ ${it.name || typeToLabel(it.type)}
+ ${typeToLabel(it.type)}
+
+
${formatDist(it._distance)}
+
`
+ )
+ .join('');
+
+ return `
+
+
+
附近 ${items.length} 个资源
+
${rows}
+
点击列表项查看详情并规划路线
+
`;
+}
diff --git a/src/assets/mapUtils/map.ts b/src/assets/mapUtils/map.ts
index b654ed5..e4ce136 100644
--- a/src/assets/mapUtils/map.ts
+++ b/src/assets/mapUtils/map.ts
@@ -1,7 +1,20 @@
import AMapLoader from '@amap/amap-jsapi-loader';
import china from '/@/assets/lngLat/china.ts';
import chinaInner from '/@/assets/lngLat/chinaInner.ts';
-import { createCircle, createIcon, getCenter, getLonLat, typeToIcon, typeToIcon1 } from '/@/assets/mapUtils/commonFun.ts';
+import {
+ buildClusterIcon,
+ buildClusterInfoContent,
+ clusterByRadius,
+ createCircle,
+ createIcon,
+ createNumberedMarkerIcon,
+ getCenter,
+ getClusterCenter,
+ getLonLat,
+ haversine,
+ typeToIcon,
+ typeToIcon1,
+} from '/@/assets/mapUtils/commonFun.ts';
import { aircraftContent, alarmIcon, female, male, watchIconR } from '/@/components/chinaMap/chinaHooks.ts';
import { isFunction } from '/@/utils/is.ts';
import { Callback, ExtDataType, InitMap, MarkerOption, ResultType } from '/@/assets/mapUtils/mapFunTypes.ts';
@@ -27,6 +40,9 @@ export const Map = {
mapDriverOrWalkLoading: false,
layerGroup: null,
layerData: null,
+ clusterInfoWindow: null, // 簇列表弹窗(聚合 marker 点击后展示)
+ clusterRadius: 30, // 聚合半径,单位米
+ clusterUserLocation: null, // 用户位置 [lng, lat],用于列表显示距离
/**
* @desc 地图舒适化
* @param el dom元素 id选择器
@@ -146,6 +162,11 @@ export const Map = {
}
this.clearActiveHospital();
this.clearInfoModal();
+ // 关闭簇列表弹窗(聚合 marker 列表弹窗)
+ if (this.clusterInfoWindow) {
+ this.clusterInfoWindow.close();
+ this.clusterInfoWindow = null;
+ }
// this.map.remove(this.airMarkerList);
}, //
/**
@@ -322,6 +343,227 @@ export const Map = {
});
return arr;
},
+ /**
+ * @desc 创建带聚合的覆盖物组:距离阈值内的点位合并为聚合 marker(最近资源图标+数字角标),
+ * 点击聚合 marker 弹出 InfoWindow 展示簇内点位列表;簇外点位独立渲染。
+ * @param result 待渲染的点位(医院/医疗点/AED 等)
+ * @param extData 与 createOverlay1 一致,content/extOption 用于 label 等
+ * @param callback 单个点位被选中时的回调(被聚合列表选中也会调用,签名相同)
+ * @param computedCenter 是否计算视野中心
+ * @param iconType 同 createOverlay1(保留参数占位)
+ * @param radius 聚合半径,单位米,默认 30
+ * @param userLocation 用户位置 [lng, lat],用于挑选最近资源 + 簇列表显示距离
+ */
+ async createClusterOverlay(
+ result: ResultType[],
+ extData?: ExtDataType,
+ callback?: Callback,
+ computedCenter?: boolean,
+ iconType?: string,
+ radius?: number,
+ userLocation?: [number, number]
+ ) {
+ this.removeOverlayGroup();
+ this.overlayGroup = new AMap.OverlayGroup();
+ // 关闭上一次的簇列表弹窗
+ if (this.clusterInfoWindow) {
+ this.clusterInfoWindow.close();
+ this.clusterInfoWindow = null;
+ }
+
+ const r = radius || this.clusterRadius || 30;
+ if (userLocation) {
+ this.clusterUserLocation = userLocation;
+ }
+
+ // 过滤掉缺少经纬度或类型的点
+ const validItems: any[] = [];
+ for (const item of result || []) {
+ const lon = Number(item.lon ?? item.longitude ?? item.lng);
+ const lat = Number(item.lat ?? item.latitude);
+ const type = item.type;
+ if (!lon || !lat || !type) continue;
+ validItems.push({ ...item, lon, lat });
+ }
+
+ // 单链聚类(30m 内合并)
+ const clusters = clusterByRadius(validItems, r);
+ const clusteredSet = new Set();
+ for (const c of clusters) {
+ for (const item of c) clusteredSet.add(item);
+ }
+ const singles = validItems.filter((it) => !clusteredSet.has(it));
+
+ const allMarkers: any[] = [];
+
+ // 收集所有"位置点":每个聚合点(按 cluster center)和每个独立点都是一个位置点
+ // 统一按距离用户位置升序排序后连续编号 1, 2, 3, ...
+ // 这样聚合点和独立 marker 不会各自从 #1 开始,地图上编号全局唯一
+ const positionPoints: any[] = [];
+ for (const cluster of clusters) {
+ const [cLng, cLat] = getClusterCenter(cluster);
+ positionPoints.push({ kind: 'cluster', cluster, cLng, cLat });
+ }
+ for (const item of singles) {
+ positionPoints.push({ kind: 'single', item });
+ }
+ if (userLocation) {
+ positionPoints.sort((a, b) => {
+ const da =
+ a.kind === 'cluster'
+ ? haversine(userLocation[0], userLocation[1], a.cLng, a.cLat)
+ : haversine(userLocation[0], userLocation[1], a.item.lon, a.item.lat);
+ const db =
+ b.kind === 'cluster'
+ ? haversine(userLocation[0], userLocation[1], b.cLng, b.cLat)
+ : haversine(userLocation[0], userLocation[1], b.item.lon, b.item.lat);
+ return da - db;
+ });
+ }
+
+ // 同步渲染聚合 marker(HTML,无异步依赖)
+ const singleMarkerRefs: Array<{ marker: any; item: any; number: number }> = [];
+ positionPoints.forEach((p, idx) => {
+ const number = idx + 1;
+ if (p.kind === 'cluster') {
+ // 聚合 marker:纯色圆 + 数量 N + 左上角位置编号角标
+ const clusterMarker: any = new AMap.Marker({
+ position: [p.cLng, p.cLat],
+ content: buildClusterIcon(p.cluster.length, p.cluster, userLocation, number),
+ clickable: true,
+ offset: new AMap.Pixel(-22, -22), // 主圈 44x44,中心对准坐标
+ extData: { __isCluster: true, items: p.cluster, clusterNumber: number },
+ zIndex: 110,
+ });
+ // 点击聚合 → 打开 InfoWindow
+ clusterMarker.on('click', () => {
+ this.openClusterInfoWindow(p.cluster, [p.cLng, p.cLat], callback);
+ });
+ this.overlayGroup.setMap(this.map);
+ this.overlayGroup.addOverlay(clusterMarker);
+ allMarkers.push(clusterMarker);
+ } else {
+ // 同步渲染独立 marker:先用 typeToIcon 的纯 url(保证渲染成功,无角标也行)
+ // 后续异步优化:用 canvas 画带角标的图标,setIcon 替换
+ const iconUrl: string = typeToIcon(p.item.type);
+ const startMarker: any = new AMap.Marker({
+ position: [p.item.lon, p.item.lat],
+ icon: iconUrl,
+ clickable: true,
+ offset: [-18, -32],
+ maxZoom: 14,
+ extData: p.item,
+ label: {
+ content: isFunction(extData?.content) && extData?.content(p.item),
+ },
+ zIndex: 99,
+ ...extData?.extOption,
+ });
+ this.overlayGroup.setMap(this.map);
+ this.overlayGroup.addOverlay(startMarker);
+ allMarkers.push(startMarker);
+ // 记录独立 marker 引用,后续异步替换为带角标图标
+ singleMarkerRefs.push({ marker: startMarker, item: p.item, number });
+ }
+ });
+
+ // 异步优化:为独立 marker 叠加数字角标(canvas 失败也不影响主渲染流程)
+ // 主渲染已经完成(marker 已上地图),这里只是 setIcon 替换图标
+ singleMarkerRefs.forEach(({ marker, item, number }) => {
+ createNumberedMarkerIcon(typeToIcon(item.type), number, item.type)
+ .then((numberedUrl) => {
+ if (numberedUrl && marker && typeof marker.setIcon === 'function') {
+ try {
+ marker.setIcon(numberedUrl);
+ } catch (e) {
+ // 静默:setIcon 失败不影响
+ }
+ }
+ })
+ .catch(() => {
+ // 静默:canvas 失败保留原 url 图标
+ });
+ });
+
+ // 独立 marker 点击 → 直接走原回调
+ this.overlayGroup.on('click', (e: any) => {
+ const ext = e?.target?.getExtData?.();
+ if (!ext || ext.__isCluster) return; // 聚合已自行处理
+ if (!isFunction(callback)) return;
+ callback(ext);
+ });
+
+ if (computedCenter && allMarkers.length) {
+ try {
+ this.map && this.map.setFitView(allMarkers, false, [50, 50, 0, 50]);
+ } catch (e) {
+ console.warn('[createClusterOverlay] setFitView failed:', e);
+ }
+ }
+ return allMarkers;
+ },
+ /**
+ * @desc 打开簇列表 InfoWindow,点击列表项触发与单点点击相同的回调
+ */
+ openClusterInfoWindow(cluster: any[], position: [number, number], callback?: Callback) {
+ if (!this.map || !AMap) return;
+ // 关闭上一次的
+ if (this.clusterInfoWindow) {
+ this.clusterInfoWindow.close();
+ this.clusterInfoWindow = null;
+ }
+ // 把 HTML 转成 DOM 元素直接传给 InfoWindow,避免 setTimeout + querySelector 的时序问题
+ // 事件直接绑在元素上,AMap 插入到地图 DOM 后 listener 依然生效(事件 listener 跟元素绑定,不跟 DOM 位置绑定)
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = buildClusterInfoContent(cluster, this.clusterUserLocation);
+ const root = wrapper.querySelector('.cluster-info-window') as HTMLElement | null;
+
+ const closeWindow = () => {
+ if (this.clusterInfoWindow) {
+ this.clusterInfoWindow.close();
+ this.clusterInfoWindow = null;
+ }
+ };
+
+ if (root) {
+ // 列表行点击
+ const rows = root.querySelectorAll('.cluster-row');
+ rows.forEach((row) => {
+ row.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const idx = Number((row as HTMLElement).getAttribute('data-idx') || 0);
+ const target = cluster[idx];
+ if (!target) return;
+ closeWindow();
+ if (isFunction(callback)) callback(target);
+ });
+ row.addEventListener('mouseenter', () => {
+ (row as HTMLElement).style.background = 'rgba(255,255,255,0.08)';
+ });
+ row.addEventListener('mouseleave', () => {
+ (row as HTMLElement).style.background = 'transparent';
+ });
+ });
+ // 关闭按钮
+ const closeBtn = root.querySelector('.cluster-info-close');
+ if (closeBtn) {
+ closeBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ closeWindow();
+ });
+ }
+ }
+
+ this.clusterInfoWindow = new AMap.InfoWindow({
+ position,
+ offset: new AMap.Pixel(0, -10),
+ content: wrapper,
+ isCustom: true,
+ autoMove: true,
+ closeWhenClickMap: true,
+ });
+ this.clusterInfoWindow.open(this.map, position);
+ },
clearOverlay() {
if (this.overlayGroup) {
this.map.remove(this.overlayGroup);
diff --git a/src/views/indexSun.vue b/src/views/indexSun.vue
index 09ee838..3c5704d 100644
--- a/src/views/indexSun.vue
+++ b/src/views/indexSun.vue
@@ -436,37 +436,76 @@
centerMapRef.value.setStartPosition(data);
const a = ((await getHospitalList(data.hospitalList, point)) as any) || [];
hospitalList.value = a.sort((a: any, b: any) => a.distance - b.distance);
- const arr = await Map.createOverlay1(
- hospitalList.value.concat([{ latitude: data.lat, longitude: data.lon }]),
- {
- content: (val: any) => {
- return `
-
`;
+ // 优先走聚合层;如抛错则回退到原 createOverlay1,保证至少能渲染
+ let arr: any[];
+ try {
+ arr = await Map.createClusterOverlay(
+ hospitalList.value.concat([{ latitude: data.lat, longitude: data.lon }]),
+ {
+ content: (val: any) => {
+ return `
+
`;
+ },
},
- },
- async (val) => {
- if (Map.mapDriverOrWalkLoading) return;
- Map.createInfoModal({ option: val, content: infoContentSpecial(val) }, false);
- const end = await Map.getAddressByPoint([val.lon, val.lat]);
- if (!data?.address) {
- data['address'] = await Map.getAddressByPoint([data.lon, data.lat]);
- }
- centerMapRef.value.getSearch({
- startPosition: [data.lon, data.lat],
- endPosition: [val.lon, val.lat],
- start: data['address'],
- end,
- });
- ('');
- },
- true,
- '1'
- );
+ async (val) => {
+ if (Map.mapDriverOrWalkLoading) return;
+ Map.createInfoModal({ option: val, content: infoContentSpecial(val) }, false);
+ const end = await Map.getAddressByPoint([val.lon, val.lat]);
+ if (!data?.address) {
+ data['address'] = await Map.getAddressByPoint([data.lon, data.lat]);
+ }
+ centerMapRef.value.getSearch({
+ startPosition: [data.lon, data.lat],
+ endPosition: [val.lon, val.lat],
+ start: data['address'],
+ end,
+ });
+ ('');
+ },
+ true,
+ '1',
+ 30,
+ [data.lon, data.lat]
+ );
+ } catch (e) {
+ console.error('[confirmHelp] createClusterOverlay 失败,回退到 createOverlay1:', e);
+ arr = await Map.createOverlay1(
+ hospitalList.value.concat([{ latitude: data.lat, longitude: data.lon }]),
+ {
+ content: (val: any) => {
+ return `
+
`;
+ },
+ },
+ async (val) => {
+ if (Map.mapDriverOrWalkLoading) return;
+ Map.createInfoModal({ option: val, content: infoContentSpecial(val) }, false);
+ const end = await Map.getAddressByPoint([val.lon, val.lat]);
+ if (!data?.address) {
+ data['address'] = await Map.getAddressByPoint([data.lon, data.lat]);
+ }
+ centerMapRef.value.getSearch({
+ startPosition: [data.lon, data.lat],
+ endPosition: [val.lon, val.lat],
+ start: data['address'],
+ end,
+ });
+ ('');
+ },
+ true,
+ '1'
+ );
+ }
Map.getAddressByPoint(point)
.then((res) => {