MapPanel.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. <template>
  2. <div class="map-panel">
  3. <div ref="mapRef" class="leaflet-map"></div>
  4. <div class="map-toolbar">
  5. <a-space>
  6. <a-switch v-model:checked="stationVisible" size="small" />
  7. <span>场站点</span>
  8. </a-space>
  9. </div>
  10. </div>
  11. </template>
  12. <script setup>
  13. import {nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue';
  14. const jiangmenStations = [
  15. {name: '江门站', lat: 22.5789, lng: 113.0819, type: 'solar', status: 'normal', power: 128.6},
  16. {name: '江门站-光伏侧', lat: 22.66, lng: 112.96, type: 'solar', status: 'normal', power: 86.2},
  17. {name: '江门站-储能侧', lat: 22.48, lng: 113.18, type: 'solar', status: 'warning', power: 154.8}
  18. ];
  19. const props = defineProps({
  20. stations: {
  21. type: Array,
  22. default: () => [
  23. {name: '甘肃酒泉风电场', lat: 39.74, lng: 98.51, type: 'wind', status: 'normal', power: 128.6},
  24. {name: '宁夏中卫光伏站', lat: 37.52, lng: 105.18, type: 'solar', status: 'normal', power: 86.2},
  25. {name: '陕西榆林风光站', lat: 38.29, lng: 109.74, type: 'hybrid', status: 'warning', power: 154.8},
  26. {name: '青海海南光伏站', lat: 36.28, lng: 100.62, type: 'solar', status: 'normal', power: 72.4},
  27. {name: '内蒙古鄂尔多斯风电场', lat: 39.61, lng: 109.78, type: 'wind', status: 'normal', power: 190.3}
  28. ]
  29. },
  30. useFallbackStations: {
  31. type: Boolean,
  32. default: true
  33. }
  34. });
  35. const mapRef = ref(null);
  36. const stationVisible = ref(true);
  37. let map;
  38. let provinceLayer;
  39. let labelLayer;
  40. let stationLayer;
  41. const ensureLeaflet = () => {
  42. if (window.L) {
  43. return Promise.resolve(window.L);
  44. }
  45. return new Promise((resolve, reject) => {
  46. const existingCss = document.querySelector('link[data-leaflet-css]');
  47. if (!existingCss) {
  48. const link = document.createElement('link');
  49. link.rel = 'stylesheet';
  50. link.href = '/weather/leaflet/leaflet.css';
  51. link.dataset.leafletCss = 'true';
  52. document.head.appendChild(link);
  53. }
  54. const existingScript = document.querySelector('script[data-leaflet-js]');
  55. if (existingScript) {
  56. existingScript.addEventListener('load', () => resolve(window.L), {once: true});
  57. existingScript.addEventListener('error', reject, {once: true});
  58. return;
  59. }
  60. const script = document.createElement('script');
  61. script.src = '/weather/leaflet/leaflet.js';
  62. script.dataset.leafletJs = 'true';
  63. script.onload = () => resolve(window.L);
  64. script.onerror = reject;
  65. document.body.appendChild(script);
  66. });
  67. };
  68. const initMap = async () => {
  69. await nextTick();
  70. const L = await ensureLeaflet();
  71. if (!mapRef.value || map) {
  72. return;
  73. }
  74. map = L.map(mapRef.value, {
  75. center: [22.58, 113.08],
  76. zoom: 7,
  77. minZoom: 3,
  78. maxZoom: 10,
  79. zoomControl: false,
  80. attributionControl: false,
  81. preferCanvas: true
  82. });
  83. L.control.zoom({position: 'bottomright'}).addTo(map);
  84. await loadProvinceLayer(L);
  85. renderStations(L);
  86. setTimeout(() => map?.invalidateSize(), 120);
  87. };
  88. const loadProvinceLayer = async (L) => {
  89. try {
  90. const response = await fetch('/weather/cn-province.geojson');
  91. const geoJson = await response.json();
  92. provinceLayer = L.geoJSON(geoJson, {
  93. interactive: false,
  94. style: {
  95. color: '#9a8666',
  96. weight: 0.8,
  97. opacity: 0.78,
  98. fillColor: '#f3f1e9',
  99. fillOpacity: 0.76
  100. }
  101. }).addTo(map);
  102. labelLayer = L.layerGroup();
  103. geoJson.features.forEach((feature) => {
  104. const name = getFeatureName(feature);
  105. const position = getFeatureCenter(feature);
  106. if (!name || !position) {
  107. return;
  108. }
  109. L.marker(position, {
  110. icon: L.divIcon({
  111. className: 'province-name-label',
  112. html: `<span>${name}</span>`,
  113. iconSize: [0, 0],
  114. iconAnchor: [0, 0]
  115. }),
  116. interactive: false
  117. }).addTo(labelLayer);
  118. });
  119. labelLayer.addTo(map);
  120. } catch (error) {
  121. console.error('加载省界地图失败:', error);
  122. }
  123. };
  124. const getFeatureName = (feature) => {
  125. const properties = feature?.properties || {};
  126. return properties.name || properties.NAME || properties.NAME_CHN || properties.fullname;
  127. };
  128. const getFeatureCenter = (feature) => {
  129. const geometry = feature?.geometry;
  130. if (!geometry) {
  131. return null;
  132. }
  133. const rings = [];
  134. if (geometry.type === 'Polygon') {
  135. rings.push(geometry.coordinates?.[0] || []);
  136. } else if (geometry.type === 'MultiPolygon') {
  137. geometry.coordinates?.forEach((polygon) => rings.push(polygon?.[0] || []));
  138. }
  139. const ring = rings.sort((a, b) => b.length - a.length)[0];
  140. if (!ring?.length) {
  141. return null;
  142. }
  143. const bounds = ring.reduce(
  144. (result, point) => ({
  145. minLng: Math.min(result.minLng, point[0]),
  146. maxLng: Math.max(result.maxLng, point[0]),
  147. minLat: Math.min(result.minLat, point[1]),
  148. maxLat: Math.max(result.maxLat, point[1])
  149. }),
  150. {minLng: Infinity, maxLng: -Infinity, minLat: Infinity, maxLat: -Infinity}
  151. );
  152. return [(bounds.minLat + bounds.maxLat) / 2, (bounds.minLng + bounds.maxLng) / 2];
  153. };
  154. const renderStations = (L = window.L) => {
  155. if (!map || !L) {
  156. return;
  157. }
  158. if (stationLayer) {
  159. stationLayer.remove();
  160. }
  161. stationLayer = L.layerGroup();
  162. if (stationVisible.value) {
  163. const stations = getVisibleStations();
  164. stations.forEach((station) => {
  165. const marker = L.marker([station.lat, station.lng], {
  166. icon: L.divIcon({
  167. className: 'power-station-marker',
  168. html: `<div class="station-pin station-${station.type || 'hybrid'} ${
  169. station.status === 'warning' ? 'is-warning' : ''
  170. }"><span>${getStationIcon(station.type)}</span></div>`,
  171. iconSize: [30, 30],
  172. iconAnchor: [15, 30],
  173. popupAnchor: [0, -28]
  174. })
  175. });
  176. marker.bindTooltip(station.name, {
  177. direction: 'top',
  178. offset: [0, -24],
  179. opacity: 0.96
  180. });
  181. marker.bindPopup(`
  182. <div class="station-popup">
  183. <b>${station.name}</b>
  184. <span>类型:${getStationTypeName(station.type)}</span>
  185. <span>实时功率:${station.power || '--'} MW</span>
  186. <span>状态:${station.status === 'warning' ? '告警' : '正常'}</span>
  187. </div>
  188. `);
  189. marker.addTo(stationLayer);
  190. });
  191. if (stations.length) {
  192. const bounds = stations.map((station) => [station.lat, station.lng]);
  193. map.fitBounds(bounds, {padding: [32, 32], maxZoom: 8});
  194. }
  195. }
  196. stationLayer.addTo(map);
  197. };
  198. const getStationIcon = (type) => {
  199. const iconMap = {
  200. wind: '风',
  201. solar: '光',
  202. hybrid: '电'
  203. };
  204. return iconMap[type] || '电';
  205. };
  206. const getVisibleStations = () => {
  207. const sourceStations = props.useFallbackStations && isLegacyDefaultStations(props.stations) ? jiangmenStations : props.stations;
  208. const validStations = sourceStations
  209. .filter((station) => Number.isFinite(Number(station.lat)) && Number.isFinite(Number(station.lng)))
  210. .map((station, index) => ({
  211. ...station,
  212. lat: Number(station.lat),
  213. lng: Number(station.lng),
  214. type: station.type || 'hybrid',
  215. name: station.name || station.stationName || station.entityName || (index === 0 ? '江门站' : `江门站-${index + 1}`)
  216. }));
  217. if (validStations.length) {
  218. return validStations;
  219. }
  220. return props.useFallbackStations ? jiangmenStations : [];
  221. };
  222. const isLegacyDefaultStations = (stations) => {
  223. return stations.length === 5
  224. && Number(stations[0]?.lat) === 39.74
  225. && Number(stations[0]?.lng) === 98.51;
  226. };
  227. const getStationTypeName = (type) => {
  228. const typeMap = {
  229. wind: '风电场',
  230. solar: '光伏电站',
  231. hybrid: '风光场站'
  232. };
  233. return typeMap[type] || '新能源场站';
  234. };
  235. onMounted(initMap);
  236. onBeforeUnmount(() => {
  237. if (provinceLayer) {
  238. provinceLayer.remove();
  239. provinceLayer = null;
  240. }
  241. if (labelLayer) {
  242. labelLayer.remove();
  243. labelLayer = null;
  244. }
  245. if (stationLayer) {
  246. stationLayer.remove();
  247. stationLayer = null;
  248. }
  249. if (map) {
  250. map.remove();
  251. map = null;
  252. }
  253. });
  254. watch(stationVisible, () => renderStations());
  255. watch(
  256. () => props.stations,
  257. () => renderStations(),
  258. {deep: true}
  259. );
  260. </script>
  261. <style lang="less" scoped>
  262. .map-panel {
  263. position: relative;
  264. min-height: 420px;
  265. overflow: hidden;
  266. background: #eef1f4;
  267. border-radius: 6px;
  268. .leaflet-map {
  269. position: absolute;
  270. inset: 0;
  271. background: #f5f4ee;
  272. }
  273. .map-toolbar {
  274. position: absolute;
  275. z-index: 900;
  276. top: 14px;
  277. left: 14px;
  278. right: 14px;
  279. display: flex;
  280. align-items: center;
  281. justify-content: flex-end;
  282. pointer-events: none;
  283. :deep(.ant-space) {
  284. height: 32px;
  285. padding: 0 10px;
  286. border: 1px solid rgba(192, 199, 209, 0.72);
  287. border-radius: 6px;
  288. color: #334155;
  289. background: rgba(255, 255, 255, 0.9);
  290. box-shadow: 0 6px 16px rgba(31, 43, 62, 0.1);
  291. font-size: 12px;
  292. pointer-events: auto;
  293. backdrop-filter: blur(6px);
  294. }
  295. }
  296. :deep(.leaflet-control-zoom) {
  297. overflow: hidden;
  298. border: 1px solid rgba(53, 64, 82, 0.18);
  299. border-radius: 6px;
  300. box-shadow: 0 8px 20px rgba(35, 48, 68, 0.12);
  301. }
  302. :deep(.leaflet-control-zoom a) {
  303. color: #263445;
  304. background: rgba(255, 255, 255, 0.94);
  305. }
  306. }
  307. :global(.power-station-marker) {
  308. border: none;
  309. background: transparent;
  310. }
  311. :global(.province-name-label) {
  312. border: none;
  313. background: transparent;
  314. pointer-events: none;
  315. }
  316. :global(.province-name-label span) {
  317. display: block;
  318. width: max-content;
  319. color: rgba(45, 55, 72, 0.72);
  320. font-size: 12px;
  321. font-weight: 600;
  322. text-shadow:
  323. 0 0 3px #fff,
  324. 0 0 3px #fff;
  325. transform: translate(-50%, -50%);
  326. white-space: nowrap;
  327. }
  328. :global(.power-station-marker .station-pin) {
  329. display: grid;
  330. place-items: center;
  331. width: 30px;
  332. height: 30px;
  333. border: 2px solid #fff;
  334. border-radius: 7px;
  335. color: #fff;
  336. background: #1da1f2;
  337. box-shadow: 0 8px 18px rgba(16, 92, 158, 0.28);
  338. font-size: 12px;
  339. font-weight: 700;
  340. }
  341. :global(.power-station-marker .station-pin::after) {
  342. position: absolute;
  343. left: 50%;
  344. bottom: -6px;
  345. width: 8px;
  346. height: 8px;
  347. border: 2px solid #fff;
  348. border-top: 0;
  349. border-left: 0;
  350. background: inherit;
  351. content: '';
  352. transform: translateX(-50%) rotate(45deg);
  353. }
  354. :global(.power-station-marker .station-wind) {
  355. background: #2d9cdb;
  356. }
  357. :global(.power-station-marker .station-solar) {
  358. background: #f5a623;
  359. }
  360. :global(.power-station-marker .station-hybrid) {
  361. background: #24a148;
  362. }
  363. :global(.power-station-marker .is-warning) {
  364. background: #f5222d;
  365. }
  366. :global(.station-popup) {
  367. display: grid;
  368. gap: 4px;
  369. min-width: 150px;
  370. color: #334155;
  371. }
  372. :global(.station-popup b) {
  373. color: #0f172a;
  374. }
  375. </style>