GisMapCanvas.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. <template>
  2. <div ref="mapContainer" class="gis-map-canvas"></div>
  3. </template>
  4. <script setup>
  5. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  6. import { convertCoord } from '@/utils/coordTransform'
  7. import { getMapClusterIcon, getMonitoringPointIcon } from '@/utils/gisMapColors'
  8. import { mergeMapPoints } from '@/utils/gisMapClustering'
  9. const props = defineProps({
  10. points: { type: Array, default: () => [] },
  11. lines: { type: Array, default: () => [] },
  12. center: { type: Object, default: () => ({ lng: 110.3965, lat: 28.4638 }) },
  13. zoom: { type: Number, default: 15 },
  14. markerSize: { type: Number, default: 28 },
  15. clusterRadius: { type: Number, default: 48 },
  16. enableClustering: { type: Boolean, default: true },
  17. clusterLabel: { type: String, default: '点位集合' },
  18. showMarkerName: { type: Boolean, default: true },
  19. mapStyleId: { type: String, default: 'a0f4e6e6f9e7a6c5d8e7f7e7f7e7f7e5' }
  20. })
  21. const emit = defineEmits(['point-click', 'line-click', 'cluster-click', 'map-click', 'ready', 'error'])
  22. const mapContainer = ref(null)
  23. const mapError = ref('')
  24. let mapInstance = null
  25. let overlays = []
  26. let bmapLoadTimer = null
  27. let disposed = false
  28. const convertedCenter = computed(() => convertCoord(props.center.lng, props.center.lat))
  29. function escapeHtml(value) {
  30. return String(value ?? '')
  31. .replace(/&/g, '&amp;')
  32. .replace(/</g, '&lt;')
  33. .replace(/>/g, '&gt;')
  34. .replace(/"/g, '&quot;')
  35. .replace(/'/g, '&#39;')
  36. }
  37. function waitForBMapGL() {
  38. return new Promise((resolve, reject) => {
  39. if (window.BMapGL) {
  40. resolve(window.BMapGL)
  41. return
  42. }
  43. if (disposed) {
  44. reject(new Error('GIS map component disposed'))
  45. return
  46. }
  47. let attempts = 0
  48. bmapLoadTimer = setInterval(() => {
  49. attempts += 1
  50. if (disposed) {
  51. clearInterval(bmapLoadTimer)
  52. reject(new Error('GIS map component disposed'))
  53. return
  54. }
  55. if (window.BMapGL) {
  56. clearInterval(bmapLoadTimer)
  57. resolve(window.BMapGL)
  58. return
  59. }
  60. if (attempts >= 100) {
  61. clearInterval(bmapLoadTimer)
  62. reject(new Error('百度地图加载超时'))
  63. }
  64. }, 200)
  65. })
  66. }
  67. async function initMap() {
  68. if (!mapContainer.value) return
  69. try {
  70. const BMapGL = await waitForBMapGL()
  71. if (disposed || !mapContainer.value) return
  72. mapInstance = new BMapGL.Map(mapContainer.value, { enableMapClick: true })
  73. const [lng, lat] = convertedCenter.value
  74. mapInstance.centerAndZoom(new BMapGL.Point(lng, lat), props.zoom)
  75. mapInstance.enableScrollWheelZoom(true)
  76. mapInstance.setMapStyleV2({ styleId: props.mapStyleId })
  77. mapInstance.addEventListener('click', (event) => {
  78. const point = event?.latLng || event?.latlng || event?.point || event?.target?.point
  79. const lng = typeof point?.lng === 'function' ? point.lng() : point?.lng
  80. const lat = typeof point?.lat === 'function' ? point.lat() : point?.lat
  81. if (point && Number.isFinite(Number(lng)) && Number.isFinite(Number(lat))) {
  82. emit('map-click', { lng: Number(lng), lat: Number(lat) })
  83. }
  84. })
  85. mapInstance.addEventListener('moveend', renderScene)
  86. mapInstance.addEventListener('zoomend', renderScene)
  87. renderScene()
  88. emit('ready', mapInstance)
  89. } catch (error) {
  90. mapError.value = '百度地图初始化失败,请检查网络或地图服务配置。'
  91. emit('error', mapError.value)
  92. }
  93. }
  94. function clearScene() {
  95. if (!mapInstance) return
  96. overlays.forEach((overlay) => {
  97. try {
  98. mapInstance.removeOverlay(overlay)
  99. } catch (_) {
  100. // ignore cleanup errors
  101. }
  102. })
  103. overlays = []
  104. }
  105. function createMarkerIcon(BMapGL, iconUrl, size) {
  106. return new BMapGL.Icon(iconUrl, new BMapGL.Size(size, size), {
  107. anchor: new BMapGL.Size(size / 2, size),
  108. imageSize: new BMapGL.Size(size, size)
  109. })
  110. }
  111. function addPointMarker(BMapGL, point) {
  112. if (!Number.isFinite(Number(point.lng)) || !Number.isFinite(Number(point.lat))) return
  113. const [lng, lat] = convertCoord(point.lng, point.lat)
  114. const name = point.name || point.equipmentName || point.facilityName || point.label || ''
  115. const iconUrl = point.iconUrl || getMonitoringPointIcon(point)
  116. const position = new BMapGL.Point(lng, lat)
  117. const marker = new BMapGL.Marker(position, {
  118. icon: createMarkerIcon(BMapGL, iconUrl, props.markerSize),
  119. enableDragging: false
  120. })
  121. marker.addEventListener('click', (event) => {
  122. event?.domEvent?.stopPropagation?.()
  123. emit('point-click', point)
  124. })
  125. mapInstance.addOverlay(marker)
  126. overlays.push(marker)
  127. if (props.showMarkerName && name) {
  128. const label = new BMapGL.Label(`<div class="gis-marker-label-text">${escapeHtml(name)}</div>`, {
  129. position,
  130. offset: new BMapGL.Size(-28, -props.markerSize - 18)
  131. })
  132. label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
  133. label.addEventListener('click', (event) => {
  134. event?.domEvent?.stopPropagation?.()
  135. emit('point-click', point)
  136. })
  137. mapInstance.addOverlay(label)
  138. overlays.push(label)
  139. }
  140. }
  141. function addClusterMarker(BMapGL, cluster) {
  142. const [positionLng, positionLat] = convertCoord(cluster.position.lng, cluster.position.lat)
  143. const position = new BMapGL.Point(positionLng, positionLat)
  144. const marker = new BMapGL.Marker(position, {
  145. icon: createMarkerIcon(BMapGL, getMapClusterIcon(cluster.points), props.markerSize),
  146. enableDragging: false
  147. })
  148. marker.addEventListener('click', (event) => {
  149. event?.domEvent?.stopPropagation?.()
  150. focusCluster(cluster)
  151. if (isClusterUnexpandable(cluster)) emit('cluster-click', cluster)
  152. })
  153. mapInstance.addOverlay(marker)
  154. overlays.push(marker)
  155. const label = new BMapGL.Label(`<div class="gis-cluster-label-text">${escapeHtml(props.clusterLabel)}:${cluster.count}</div>`, {
  156. position,
  157. offset: new BMapGL.Size(-28, -props.markerSize - 18)
  158. })
  159. label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
  160. label.addEventListener('click', (event) => {
  161. event?.domEvent?.stopPropagation?.()
  162. focusCluster(cluster)
  163. if (isClusterUnexpandable(cluster)) emit('cluster-click', cluster)
  164. })
  165. mapInstance.addOverlay(label)
  166. overlays.push(label)
  167. }
  168. function isClusterUnexpandable(cluster) {
  169. const bounds = cluster?.bounds
  170. if (!bounds) return false
  171. return bounds.minLng === bounds.maxLng && bounds.minLat === bounds.maxLat
  172. }
  173. function projectMapPoint({ lng, lat }) {
  174. const [bmapLng, bmapLat] = convertCoord(lng, lat)
  175. const pixel = mapInstance.pointToPixel(new BMapGL.Point(bmapLng, bmapLat))
  176. return { x: Number(pixel?.x), y: Number(pixel?.y) }
  177. }
  178. function focusCluster(cluster) {
  179. if (!mapInstance || !window.BMapGL || !cluster?.points?.length) return
  180. const bmapPoints = cluster.points.map(point => {
  181. const [lng, lat] = convertCoord(point.lng, point.lat)
  182. return new BMapGL.Point(lng, lat)
  183. })
  184. if (bmapPoints.length === 1) {
  185. locatePoint(cluster.points[0])
  186. return
  187. }
  188. mapInstance.setViewport(bmapPoints, { margins: [110, 110, 110, 110] })
  189. }
  190. function addLine(BMapGL, line) {
  191. const points = (line.points || []).filter((point) => Number.isFinite(Number(point.lng)) && Number.isFinite(Number(point.lat)))
  192. if (points.length < 2) return
  193. const bmapPoints = points.map((point) => {
  194. const [lng, lat] = convertCoord(point.lng, point.lat)
  195. return new BMapGL.Point(lng, lat)
  196. })
  197. const polyline = new BMapGL.Polyline(bmapPoints, {
  198. strokeColor: line.color || '#3498db',
  199. strokeWeight: line.strokeWeight || 6,
  200. strokeOpacity: line.strokeOpacity ?? 0.92,
  201. strokeStyle: 'solid'
  202. })
  203. polyline.addEventListener('click', (event) => {
  204. event?.domEvent?.stopPropagation?.()
  205. emit('line-click', line)
  206. })
  207. mapInstance.addOverlay(polyline)
  208. overlays.push(polyline)
  209. }
  210. function renderScene() {
  211. if (!mapInstance || !window.BMapGL) return
  212. clearScene()
  213. const BMapGL = window.BMapGL
  214. const renderPoints = props.points
  215. const renderedPoints = props.enableClustering
  216. ? mergeMapPoints(renderPoints, projectMapPoint, props.clusterRadius)
  217. : renderPoints.map(point => ({ type: 'point', point, position: { lng: point.lng, lat: point.lat } }))
  218. props.lines.forEach((line) => addLine(BMapGL, line))
  219. renderedPoints.forEach((item) => {
  220. if (item.type === 'cluster') addClusterMarker(BMapGL, item)
  221. else addPointMarker(BMapGL, item.point)
  222. })
  223. }
  224. function fitBounds() {
  225. if (!mapInstance || !window.BMapGL) return
  226. const points = [
  227. ...props.points.map((point) => ({ lng: point.lng, lat: point.lat })),
  228. ...props.lines.flatMap((line) => line.points || [])
  229. ].filter((point) => Number.isFinite(Number(point.lng)) && Number.isFinite(Number(point.lat)))
  230. if (!points.length) return
  231. const bmapPoints = points.map((point) => {
  232. const [lng, lat] = convertCoord(point.lng, point.lat)
  233. return new window.BMapGL.Point(lng, lat)
  234. })
  235. mapInstance.setViewport(bmapPoints, { margins: [70, 70, 70, 70] })
  236. }
  237. function locatePoint(point) {
  238. if (!mapInstance || !window.BMapGL || !Number.isFinite(Number(point?.lng)) || !Number.isFinite(Number(point?.lat))) return
  239. const [lng, lat] = convertCoord(point.lng, point.lat)
  240. mapInstance.centerAndZoom(new window.BMapGL.Point(lng, lat), 17)
  241. }
  242. function zoomIn() {
  243. if (mapInstance) mapInstance.zoomIn()
  244. }
  245. function zoomOut() {
  246. if (mapInstance) mapInstance.zoomOut()
  247. }
  248. watch(() => [props.points, props.lines], () => {
  249. nextTick(renderScene)
  250. }, { deep: true })
  251. onMounted(async () => {
  252. await nextTick()
  253. await initMap()
  254. })
  255. onBeforeUnmount(() => {
  256. disposed = true
  257. if (bmapLoadTimer) {
  258. clearInterval(bmapLoadTimer)
  259. bmapLoadTimer = null
  260. }
  261. clearScene()
  262. try {
  263. mapInstance?.destroy()
  264. } catch (_) {
  265. // ignore BMapGL cleanup errors
  266. }
  267. mapInstance = null
  268. })
  269. defineExpose({
  270. fitBounds,
  271. locatePoint,
  272. focusCluster,
  273. zoomIn,
  274. zoomOut,
  275. mapError
  276. })
  277. </script>
  278. <style scoped>
  279. .gis-map-canvas {
  280. width: 100%;
  281. height: 100%;
  282. min-height: 420px;
  283. background: #e9f0e5;
  284. }
  285. </style>
  286. <style>
  287. .gis-marker-label {
  288. border: none !important;
  289. background: transparent !important;
  290. padding: 0 !important;
  291. }
  292. .gis-marker-label-text,
  293. .gis-cluster-label-text {
  294. box-sizing: border-box;
  295. width: 56px;
  296. margin-bottom: 4px;
  297. padding: 2px 1px;
  298. overflow: hidden;
  299. color: #22405c;
  300. background: rgba(255, 255, 255, 0.92);
  301. border: 1px solid rgba(118, 151, 181, 0.32);
  302. border-radius: 4px;
  303. box-shadow: 0 2px 6px rgba(31, 74, 121, 0.12);
  304. text-align: center;
  305. }
  306. .gis-marker-label-text {
  307. font-size: 9px;
  308. line-height: 12px;
  309. font-weight: 600;
  310. overflow-wrap: anywhere;
  311. }
  312. .gis-cluster-label-text {
  313. width: auto;
  314. min-width: 56px;
  315. font-size: 11px;
  316. line-height: 16px;
  317. font-weight: 700;
  318. }
  319. </style>