BaseStationQueryMap.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. <template>
  2. <div :class="prefixCls" class="base-station-query-map">
  3. <div ref="mapBoxRef" class="base-station-query-map__box">
  4. <div ref="mapRef" class="base-station-query-map__canvas"></div>
  5. <div v-if="errorMsg" class="base-station-query-map__error">{{ errorMsg }}</div>
  6. <div v-if="!points.length" class="base-station-query-map__empty">暂无基站坐标数据</div>
  7. </div>
  8. </div>
  9. </template>
  10. <script lang="ts" setup>
  11. import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue';
  12. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  13. import {
  14. ElectronPmtilesSource,
  15. getOfflineMapDir,
  16. getOfflineMapFilePath,
  17. OFFLINE_MAP_DIR_CHANGED_EVENT,
  18. toLocalMapUrl,
  19. } from '@jeesite/core/utils/map/offlineMap';
  20. import maplibregl from 'maplibre-gl';
  21. import type { Map as MapLibreMap, Popup as MapLibrePopup } from 'maplibre-gl';
  22. import 'maplibre-gl/dist/maplibre-gl.css';
  23. import { PMTiles, Protocol } from 'pmtiles';
  24. import mapPinSvg from '../../behaviorSpacetime/assets/location_on_baseline.svg?raw';
  25. export interface BaseStationMapPoint {
  26. id: string;
  27. lng: number;
  28. lat: number;
  29. title: string;
  30. address?: string;
  31. regionId?: string;
  32. cellId?: string;
  33. longitudeText?: string;
  34. latitudeText?: string;
  35. range?: string;
  36. operatorName?: string;
  37. occurTime?: string;
  38. personNo?: string;
  39. }
  40. type BaseStationWindow = Window & {
  41. __BEHAVIOR_SPACETIME_STYLE__?: string | Record<string, any>;
  42. };
  43. const props = withDefaults(
  44. defineProps<{
  45. points?: BaseStationMapPoint[];
  46. }>(),
  47. {
  48. points: () => [],
  49. },
  50. );
  51. const { prefixCls } = useDesign('base-station-query-map');
  52. const STYLE_PATH = `${import.meta.env.BASE_URL}resource/map/behavior-spacetime-style.json`;
  53. const DEFAULT_CENTER: [number, number] = [104.0668, 30.5728];
  54. const DEFAULT_ZOOM = 10.2;
  55. const MAP_MAX_ZOOM = 14;
  56. const CHINA_BOUNDS = {
  57. minLng: 73.41788,
  58. maxLng: 134.803619,
  59. minLat: 14.27437,
  60. maxLat: 53.6555899,
  61. };
  62. const SOURCE_ID = 'base-station-query-source';
  63. const POINT_LAYER_ID = 'base-station-query-points';
  64. const MAP_PIN_ICON = 'base-station-query-pin';
  65. const PMTILES_PROTOCOL = 'pmtiles://';
  66. const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
  67. const mapRef = ref<HTMLElement | null>(null);
  68. const mapBoxRef = ref<HTMLElement | null>(null);
  69. const map = ref<MapLibreMap | null>(null);
  70. const mapLoaded = ref(false);
  71. const errorMsg = ref('');
  72. const validPoints = computed(() =>
  73. (props.points || []).filter(
  74. (item) =>
  75. Number.isFinite(Number(item?.lng)) &&
  76. Number.isFinite(Number(item?.lat)) &&
  77. Number(item.lng) >= CHINA_BOUNDS.minLng &&
  78. Number(item.lng) <= CHINA_BOUNDS.maxLng &&
  79. Number(item.lat) >= CHINA_BOUNDS.minLat &&
  80. Number(item.lat) <= CHINA_BOUNDS.maxLat,
  81. ),
  82. );
  83. let popup: MapLibrePopup | null = null;
  84. let pmtilesRegistered = false;
  85. let pmtilesProtocol: Protocol | null = null;
  86. let mapResizeObserver: ResizeObserver | null = null;
  87. const ensurePmtiles = () => {
  88. if (pmtilesRegistered) return;
  89. pmtilesProtocol = new Protocol();
  90. maplibregl.addProtocol('pmtiles', pmtilesProtocol.tile);
  91. pmtilesRegistered = true;
  92. };
  93. const ensureOfflinePmtilesArchive = () => {
  94. const filePath = getOfflineMapFilePath('china.pmtiles');
  95. if (!pmtilesProtocol || !filePath) return;
  96. if (pmtilesProtocol.get(filePath)) return;
  97. pmtilesProtocol.add(new PMTiles(new ElectronPmtilesSource(filePath) as any));
  98. };
  99. const getPublicBaseUrl = () => new URL(import.meta.env.BASE_URL || './', window.location.href).toString();
  100. const resolveStyleAssetUrl = (url: string, styleUrl: string) => {
  101. if (ABSOLUTE_URL_RE.test(url)) return toLocalMapUrl(url);
  102. if (url.startsWith('./') || url.startsWith('../')) return new URL(url, styleUrl).toString();
  103. return new URL(url.replace(/^\/+/, ''), getPublicBaseUrl()).toString();
  104. };
  105. const normalizePmtilesSources = (style: Record<string, any>, baseUrl: string, overridePmtilesUrl?: string) => {
  106. const sources = style?.sources;
  107. if (!sources || typeof sources !== 'object') return style;
  108. const normalizedSources = Object.entries(sources).reduce<Record<string, any>>((acc, [key, source]) => {
  109. const sourceItem = source as Record<string, any>;
  110. if (
  111. !sourceItem ||
  112. typeof sourceItem !== 'object' ||
  113. typeof sourceItem.url !== 'string' ||
  114. !sourceItem.url.startsWith(PMTILES_PROTOCOL)
  115. ) {
  116. acc[key] = source;
  117. return acc;
  118. }
  119. const normalizedUrl = overridePmtilesUrl
  120. ? overridePmtilesUrl
  121. : toLocalMapUrl(resolveStyleAssetUrl(sourceItem.url.slice(PMTILES_PROTOCOL.length), baseUrl));
  122. acc[key] = {
  123. ...sourceItem,
  124. url: `${PMTILES_PROTOCOL}${normalizedUrl}`,
  125. };
  126. return acc;
  127. }, {});
  128. return { ...style, sources: normalizedSources };
  129. };
  130. const getFallbackStyle = () => ({
  131. version: 8,
  132. sources: {},
  133. layers: [{ id: 'base-station-query-background', type: 'background', paint: { 'background-color': '#edf3f8' } }],
  134. });
  135. const buildExternalMapStyle = () => {
  136. const offlineMapDir = getOfflineMapDir();
  137. const pmtilesFilePath = getOfflineMapFilePath('china.pmtiles');
  138. if (!offlineMapDir || !pmtilesFilePath) return null;
  139. return {
  140. version: 8,
  141. name: 'base-station-query-offline-style',
  142. sources: {
  143. 'behavior-basemap': {
  144. type: 'vector',
  145. url: `${PMTILES_PROTOCOL}${pmtilesFilePath}`,
  146. },
  147. },
  148. layers: [
  149. {
  150. id: 'behavior-background',
  151. type: 'background',
  152. paint: { 'background-color': '#f7fafc' },
  153. },
  154. {
  155. id: 'behavior-landcover',
  156. type: 'fill',
  157. source: 'behavior-basemap',
  158. 'source-layer': 'landcover',
  159. paint: { 'fill-color': '#e7efe4', 'fill-opacity': 0.8 },
  160. },
  161. {
  162. id: 'behavior-landuse',
  163. type: 'fill',
  164. source: 'behavior-basemap',
  165. 'source-layer': 'landuse',
  166. paint: { 'fill-color': '#eef2e7', 'fill-opacity': 0.72 },
  167. },
  168. {
  169. id: 'behavior-park',
  170. type: 'fill',
  171. source: 'behavior-basemap',
  172. 'source-layer': 'park',
  173. paint: { 'fill-color': '#dcefd4', 'fill-opacity': 0.76 },
  174. },
  175. {
  176. id: 'behavior-water',
  177. type: 'fill',
  178. source: 'behavior-basemap',
  179. 'source-layer': 'water',
  180. paint: { 'fill-color': '#cfe6ff', 'fill-opacity': 0.96 },
  181. },
  182. {
  183. id: 'behavior-waterway',
  184. type: 'line',
  185. source: 'behavior-basemap',
  186. 'source-layer': 'waterway',
  187. paint: { 'line-color': '#9dc8f7', 'line-width': 1.2 },
  188. },
  189. {
  190. id: 'behavior-boundary',
  191. type: 'line',
  192. source: 'behavior-basemap',
  193. 'source-layer': 'boundary',
  194. paint: { 'line-color': '#a7b3c2', 'line-width': 0.8, 'line-opacity': 0.7 },
  195. },
  196. {
  197. id: 'behavior-transportation',
  198. type: 'line',
  199. source: 'behavior-basemap',
  200. 'source-layer': 'transportation',
  201. layout: { 'line-cap': 'round', 'line-join': 'round' },
  202. paint: { 'line-color': '#f3c985', 'line-width': 1.1 },
  203. },
  204. ],
  205. };
  206. };
  207. const getOfflineMapStylePath = () => getOfflineMapFilePath('behavior-spacetime-style.json');
  208. const readOfflineMapStyle = async () => {
  209. const ipcRenderer = (window as any)?.electron?.ipcRenderer;
  210. const filePath = getOfflineMapStylePath();
  211. if (!ipcRenderer?.invoke || !filePath) return '';
  212. try {
  213. return (await ipcRenderer.invoke('controller/os/readTextFile', { filePath, encoding: 'utf8' })) || '';
  214. } catch {
  215. return '';
  216. }
  217. };
  218. const resolveStyle = async () => {
  219. const customStyle = (window as BaseStationWindow).__BEHAVIOR_SPACETIME_STYLE__ || STYLE_PATH;
  220. try {
  221. const offlinePmtilesFilePath = getOfflineMapFilePath('china.pmtiles');
  222. const offlineStylePath = getOfflineMapStylePath();
  223. if (getOfflineMapDir() && offlinePmtilesFilePath && offlineStylePath) {
  224. const styleText = await readOfflineMapStyle();
  225. if (styleText) {
  226. return normalizePmtilesSources(JSON.parse(styleText), offlineStylePath, offlinePmtilesFilePath);
  227. }
  228. }
  229. const externalStyle = buildExternalMapStyle();
  230. if (externalStyle) {
  231. return externalStyle;
  232. }
  233. if (customStyle && typeof customStyle === 'object') {
  234. return normalizePmtilesSources(customStyle, window.location.href, offlinePmtilesFilePath || undefined);
  235. }
  236. const styleUrl = resolveStyleAssetUrl(String(customStyle), window.location.href);
  237. const response = await fetch(styleUrl, { cache: 'no-store' });
  238. if (!response.ok) throw new Error(`style request failed: ${response.status}`);
  239. const style = await response.json();
  240. return normalizePmtilesSources(style, response.url || styleUrl, offlinePmtilesFilePath || undefined);
  241. } catch (error) {
  242. errorMsg.value = error instanceof Error ? error.message : 'map style error';
  243. return getFallbackStyle();
  244. }
  245. };
  246. const toGeoJson = () => ({
  247. type: 'FeatureCollection',
  248. features: validPoints.value.map((item) => ({
  249. type: 'Feature',
  250. geometry: {
  251. type: 'Point',
  252. coordinates: [Number(item.lng), Number(item.lat)],
  253. },
  254. properties: {
  255. id: item.id,
  256. title: item.title,
  257. address: item.address || '-',
  258. regionId: item.regionId || '-',
  259. cellId: item.cellId || '-',
  260. longitude: item.longitudeText || String(item.lng),
  261. latitude: item.latitudeText || String(item.lat),
  262. range: item.range || '-',
  263. operatorName: item.operatorName || '-',
  264. occurTime: item.occurTime || '-',
  265. personNo: item.personNo || '-',
  266. },
  267. })),
  268. });
  269. const createPopupContent = (properties: Record<string, unknown>) => {
  270. const container = document.createElement('div');
  271. container.className = 'base-station-query-map-popup';
  272. const title = document.createElement('div');
  273. title.className = 'base-station-query-map-popup__title';
  274. title.textContent = String(properties.title || '基站位置');
  275. container.appendChild(title);
  276. const rows = [
  277. ['LAC', String(properties.regionId || '-')],
  278. ['CID', String(properties.cellId || '-')],
  279. ['经度', String(properties.longitude || '-')],
  280. ['纬度', String(properties.latitude || '-')],
  281. ];
  282. rows.forEach(([label, value]) => {
  283. const row = document.createElement('div');
  284. row.className = 'base-station-query-map-popup__row';
  285. row.textContent = `${label}:${value}`;
  286. container.appendChild(row);
  287. });
  288. return container;
  289. };
  290. const createMapPinImage = async (fillColor: string, size = 24): Promise<ImageData | null> => {
  291. const svgMarkup = mapPinSvg.replace(/<path\b/, `<path fill="${fillColor}"`);
  292. const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
  293. const imageElement = await new Promise<HTMLImageElement>((resolve, reject) => {
  294. const image = new Image();
  295. image.onload = () => resolve(image);
  296. image.onerror = (error) => reject(error);
  297. image.src = svgDataUrl;
  298. });
  299. const pixelRatio = 2;
  300. const logicalWidth = size;
  301. const logicalHeight = size;
  302. const canvas = document.createElement('canvas');
  303. canvas.width = logicalWidth * pixelRatio;
  304. canvas.height = logicalHeight * pixelRatio;
  305. const ctx = canvas.getContext('2d');
  306. if (!ctx) return null;
  307. ctx.scale(pixelRatio, pixelRatio);
  308. ctx.clearRect(0, 0, logicalWidth, logicalHeight);
  309. ctx.drawImage(imageElement, 0, 0, logicalWidth, logicalHeight);
  310. return ctx.getImageData(0, 0, canvas.width, canvas.height);
  311. };
  312. const registerPinImage = async (instance: MapLibreMap) => {
  313. if (instance.hasImage(MAP_PIN_ICON)) {
  314. instance.removeImage(MAP_PIN_ICON);
  315. }
  316. const image = await createMapPinImage('#1677ff', 24);
  317. if (!image) return;
  318. instance.addImage(MAP_PIN_ICON, image, { pixelRatio: 2 });
  319. };
  320. const updateSource = () => {
  321. if (!map.value || !mapLoaded.value) return;
  322. (map.value.getSource(SOURCE_ID) as any)?.setData(toGeoJson() as any);
  323. };
  324. const openPointPopup = (instance: MapLibreMap, feature?: maplibregl.MapGeoJSONFeature) => {
  325. const geometry = feature?.geometry as any;
  326. const coordinates = geometry?.coordinates;
  327. if (!feature?.properties || !Array.isArray(coordinates) || coordinates.length < 2) return;
  328. popup?.remove();
  329. popup = new maplibregl.Popup({ closeButton: false, offset: 18, maxWidth: '320px' })
  330. .setLngLat([Number(coordinates[0]), Number(coordinates[1])])
  331. .setDOMContent(createPopupContent(feature.properties as Record<string, unknown>))
  332. .addTo(instance);
  333. };
  334. const fitMap = (animate = true) => {
  335. if (!map.value || !mapLoaded.value) return;
  336. if (!validPoints.value.length) {
  337. map.value.easeTo({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, duration: animate ? 500 : 0 });
  338. return;
  339. }
  340. if (validPoints.value.length === 1) {
  341. const point = validPoints.value[0];
  342. map.value.easeTo({ center: [Number(point.lng), Number(point.lat)], zoom: 13.8, duration: animate ? 500 : 0 });
  343. return;
  344. }
  345. const bounds = new maplibregl.LngLatBounds();
  346. validPoints.value.forEach((point) => bounds.extend([Number(point.lng), Number(point.lat)]));
  347. map.value.fitBounds(bounds, { padding: 64, maxZoom: 13.8, duration: animate ? 700 : 0 });
  348. };
  349. const bindMapEvents = (instance: MapLibreMap) => {
  350. instance.on('click', POINT_LAYER_ID, (event) => {
  351. openPointPopup(instance, event.features?.[0]);
  352. });
  353. instance.on('mouseenter', POINT_LAYER_ID, () => {
  354. instance.getCanvas().style.cursor = 'pointer';
  355. });
  356. instance.on('mouseleave', POINT_LAYER_ID, () => {
  357. instance.getCanvas().style.cursor = '';
  358. });
  359. };
  360. const initMap = async () => {
  361. if (!mapRef.value || map.value) return;
  362. ensurePmtiles();
  363. ensureOfflinePmtilesArchive();
  364. const style = await resolveStyle();
  365. const instance = new maplibregl.Map({
  366. container: mapRef.value,
  367. style: style as any,
  368. center: DEFAULT_CENTER,
  369. zoom: DEFAULT_ZOOM,
  370. minZoom: 3,
  371. maxZoom: MAP_MAX_ZOOM,
  372. attributionControl: false,
  373. renderWorldCopies: false,
  374. });
  375. map.value = instance;
  376. instance.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
  377. instance.on('load', async () => {
  378. mapLoaded.value = true;
  379. await registerPinImage(instance);
  380. instance.addSource(SOURCE_ID, {
  381. type: 'geojson',
  382. data: toGeoJson() as any,
  383. });
  384. instance.addLayer({
  385. id: POINT_LAYER_ID,
  386. type: 'symbol',
  387. source: SOURCE_ID,
  388. layout: {
  389. 'icon-image': MAP_PIN_ICON,
  390. 'icon-size': ['interpolate', ['linear'], ['zoom'], 4, 0.76, 10.6, 0.88, 14, 0.96],
  391. 'icon-anchor': 'bottom',
  392. 'icon-allow-overlap': true,
  393. 'icon-ignore-placement': true,
  394. },
  395. });
  396. bindMapEvents(instance);
  397. fitMap(false);
  398. setTimeout(() => instance.resize(), 0);
  399. });
  400. instance.on('error', (event) => {
  401. errorMsg.value = event.error?.message || 'map error';
  402. });
  403. };
  404. const ensureMapReady = async () => {
  405. await nextTick();
  406. await initMap();
  407. if (!map.value) return;
  408. map.value.resize();
  409. updateSource();
  410. fitMap(false);
  411. };
  412. const observeMapContainer = () => {
  413. if (typeof ResizeObserver === 'undefined' || mapResizeObserver || !mapBoxRef.value) return;
  414. mapResizeObserver = new ResizeObserver(() => {
  415. map.value?.resize();
  416. });
  417. mapResizeObserver.observe(mapBoxRef.value);
  418. };
  419. const unobserveMapContainer = () => {
  420. mapResizeObserver?.disconnect();
  421. mapResizeObserver = null;
  422. };
  423. const recreateMap = async () => {
  424. popup?.remove();
  425. popup = null;
  426. mapLoaded.value = false;
  427. errorMsg.value = '';
  428. map.value?.remove();
  429. map.value = null;
  430. await ensureMapReady();
  431. };
  432. watch(
  433. validPoints,
  434. () => {
  435. updateSource();
  436. fitMap(false);
  437. },
  438. { deep: true },
  439. );
  440. onMounted(() => {
  441. window.addEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, recreateMap);
  442. observeMapContainer();
  443. void ensureMapReady();
  444. });
  445. onActivated(() => {
  446. map.value?.resize();
  447. });
  448. onBeforeUnmount(() => {
  449. window.removeEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, recreateMap);
  450. unobserveMapContainer();
  451. popup?.remove();
  452. popup = null;
  453. map.value?.remove();
  454. map.value = null;
  455. mapLoaded.value = false;
  456. });
  457. </script>
  458. <style lang="less">
  459. @prefix-cls: ~'jeesite-base-station-query-map';
  460. .@{prefix-cls}.base-station-query-map {
  461. display: flex;
  462. flex: 1 1 auto;
  463. min-height: 0;
  464. .base-station-query-map__box {
  465. position: relative;
  466. flex: 1 1 auto;
  467. min-height: 0;
  468. overflow: hidden;
  469. background: #edf3f8;
  470. border: 1px solid #e5e7eb;
  471. border-radius: 10px;
  472. }
  473. .base-station-query-map__canvas {
  474. width: 100%;
  475. height: 100%;
  476. }
  477. .base-station-query-map__empty {
  478. position: absolute;
  479. inset: 0;
  480. z-index: 2;
  481. display: flex;
  482. align-items: center;
  483. justify-content: center;
  484. background: rgb(248 250 252 / 68%);
  485. color: #64748b;
  486. font-size: 14px;
  487. pointer-events: none;
  488. }
  489. .base-station-query-map__error {
  490. position: absolute;
  491. top: 14px;
  492. left: 14px;
  493. z-index: 3;
  494. max-width: calc(100% - 120px);
  495. padding: 6px 10px;
  496. color: #b45309;
  497. font-size: 12px;
  498. line-height: 18px;
  499. background: rgb(255 247 237 / 94%);
  500. border: 1px solid #fdba74;
  501. border-radius: 10px;
  502. box-shadow: 0 6px 14px rgb(15 23 42 / 8%);
  503. }
  504. .maplibregl-ctrl-top-right {
  505. top: 12px;
  506. right: 12px;
  507. }
  508. .maplibregl-ctrl-group {
  509. overflow: hidden;
  510. border-radius: 12px;
  511. box-shadow: 0 10px 18px rgb(15 23 42 / 8%);
  512. }
  513. }
  514. .base-station-query-map-popup {
  515. min-width: 220px;
  516. padding: 2px 2px 0;
  517. font-size: 12px;
  518. line-height: 1.7;
  519. color: #475569;
  520. }
  521. .base-station-query-map-popup__title {
  522. margin-bottom: 4px;
  523. color: #0f172a;
  524. font-size: 13px;
  525. font-weight: 600;
  526. line-height: 20px;
  527. }
  528. .base-station-query-map-popup__row + .base-station-query-map-popup__row {
  529. margin-top: 2px;
  530. }
  531. </style>