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) => {