feat(急救服务平台): 附近资源聚合展示与路径规划优化
- 新增 Map.createClusterOverlay:30m 内点位合并为带数字徽标的聚合 marker,聚合点与独立 marker 按距离用户统一编号 1, 2, 3... - 聚合徽标使用纯色圆 + 数量 N + 左上角位置编号角标,hover 显示簇内 数量与最近资源名称 - 点击聚合 marker 弹 isCustom InfoWindow 列表,自带关闭按钮 + 选中 触发原有路径规划回调 - 独立 marker 用 typeToIcon 纯 url 同步渲染,canvas 角标异步叠加 失败不影响主渲染 - 修复 removeOverlayGroup 未关闭簇列表弹窗的 bug - confirmHelp 加 createOverlay1 fallback,聚合层异常时回退原渲染
This commit is contained in:
@@ -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
|
||||
? `<div style="
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
line-height: 22px;
|
||||
border-radius: 11px;
|
||||
background: #E74C3C;
|
||||
border: 2px solid #fff;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
">${clusterNumber}</div>`
|
||||
: '';
|
||||
|
||||
// 主圈:纯色圆 + 数量 N 在中心
|
||||
return `
|
||||
<div class="cluster-marker" title="${tip}" style="
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
">
|
||||
<div style="
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: ${bg};
|
||||
border: 3px solid ${border};
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
">${count}</div>
|
||||
${badgeHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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<string> {
|
||||
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<string, string> = {
|
||||
'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) => `
|
||||
<div class="cluster-row" data-idx="${idx}" style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
transition: background 0.2s;
|
||||
">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #3FA9F5;
|
||||
flex-shrink: 0;
|
||||
"></span>
|
||||
<span style="color: #fff; font-size: 14px;">${it.name || typeToLabel(it.type)}</span>
|
||||
<span style="color: rgba(255,255,255,0.6); font-size: 12px;">${typeToLabel(it.type)}</span>
|
||||
</div>
|
||||
<span style="color: rgba(255,255,255,0.85); font-size: 13px;">${formatDist(it._distance)}</span>
|
||||
</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="cluster-info-window" style="
|
||||
position: relative;
|
||||
width: 260px;
|
||||
background: rgba(15, 32, 60, 0.95);
|
||||
border-radius: 8px;
|
||||
padding: 12px 0;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
|
||||
box-sizing: border-box;
|
||||
">
|
||||
<button class="cluster-info-close" type="button" style="
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border: 0;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
">×</button>
|
||||
<div style="
|
||||
padding: 0 36px 8px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.15);
|
||||
">附近 ${items.length} 个资源</div>
|
||||
<div class="cluster-rows">${rows}</div>
|
||||
<div style="
|
||||
padding: 6px 14px 0;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
">点击列表项查看详情并规划路线</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
+243
-1
@@ -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);
|
||||
|
||||
+69
-30
@@ -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 `<div style="
|
||||
width: 21px;
|
||||
height: 44px;
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: -20px;" title="${val?.name}">
|
||||
</div>`;
|
||||
// 优先走聚合层;如抛错则回退到原 createOverlay1,保证至少能渲染
|
||||
let arr: any[];
|
||||
try {
|
||||
arr = await Map.createClusterOverlay(
|
||||
hospitalList.value.concat([{ latitude: data.lat, longitude: data.lon }]),
|
||||
{
|
||||
content: (val: any) => {
|
||||
return `<div style="
|
||||
width: 21px;
|
||||
height: 44px;
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: -20px;" title="${val?.name}">
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
},
|
||||
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 `<div style="
|
||||
width: 21px;
|
||||
height: 44px;
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: -20px;" title="${val?.name}">
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user