StationMapModal.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875
  1. <template>
  2. <BasicModal
  3. :open="open"
  4. :title="titleText"
  5. :showOkBtn="false"
  6. :showCancelBtn="false"
  7. :width="1100"
  8. :centered="true"
  9. wrapClassName="portrait-station-map-modal-wrap"
  10. @update:open="(value) => emit('update:open', value)"
  11. @cancel="emit('update:open', false)"
  12. >
  13. <div :class="prefixCls" class="station-map-modal">
  14. <div ref="mapBoxRef" class="station-map-modal__map-box">
  15. <div ref="mapRef" class="station-map-modal__map"></div>
  16. <div v-if="errorMsg" class="station-map-modal__error">{{ errorMsg }}</div>
  17. <div v-if="!validPoints.length" class="station-map-modal__empty">暂无可展示的基站坐标信息</div>
  18. </div>
  19. </div>
  20. </BasicModal>
  21. </template>
  22. <script lang="ts" setup>
  23. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
  24. import { BasicModal } from '@jeesite/core/components/Modal';
  25. import { useDesign } from '@jeesite/core/hooks/web/useDesign';
  26. import {
  27. ElectronPmtilesSource,
  28. getOfflineMapDir,
  29. getOfflineMapFilePath,
  30. OFFLINE_MAP_DIR_CHANGED_EVENT,
  31. toLocalMapUrl,
  32. } from '@jeesite/core/utils/map/offlineMap';
  33. import type { CallStatStationDTO } from '../../../../person/api/types/dto';
  34. import maplibregl from 'maplibre-gl';
  35. import type { Map as MapLibreMap, Popup as MapLibrePopup } from 'maplibre-gl';
  36. import 'maplibre-gl/dist/maplibre-gl.css';
  37. import { PMTiles, Protocol } from 'pmtiles';
  38. import mapPinSvg from '../../../../trans/views/behaviorSpacetime/assets/location_on_baseline.svg?raw';
  39. interface StationPoint {
  40. id: string;
  41. lng: number;
  42. lat: number;
  43. name: string;
  44. regionId: string;
  45. cellId: string;
  46. longitudeText: string;
  47. latitudeText: string;
  48. }
  49. type PortraitStationWindow = Window & {
  50. __BEHAVIOR_SPACETIME_STYLE__?: string | Record<string, any>;
  51. };
  52. const props = defineProps<{
  53. open: boolean;
  54. title?: string;
  55. stations: CallStatStationDTO[];
  56. }>();
  57. const emit = defineEmits<{
  58. (e: 'update:open', value: boolean): void;
  59. }>();
  60. const { prefixCls } = useDesign('portrait-station-map-modal');
  61. const STYLE_PATH = `${import.meta.env.BASE_URL}resource/map/behavior-spacetime-style.json`;
  62. const DEFAULT_CENTER: [number, number] = [104.0668, 30.5728];
  63. const DEFAULT_ZOOM = 10.2;
  64. const MAP_MAX_ZOOM = 14;
  65. const CHINA_BOUNDS = {
  66. minLng: 73.41788,
  67. maxLng: 134.803619,
  68. minLat: 14.27437,
  69. maxLat: 53.6555899,
  70. };
  71. const SOURCE_ID = 'portrait-station-source';
  72. const CLUSTER_LAYER_ID = 'portrait-station-clusters';
  73. const CLUSTER_COUNT_LAYER_ID = 'portrait-station-cluster-count';
  74. const POINT_LAYER_ID = 'portrait-station-points';
  75. const POINT_LABEL_LAYER_ID = 'portrait-station-point-labels';
  76. const MAP_PIN_ICON_STATION = 'portrait-station-pin';
  77. const PMTILES_PROTOCOL = 'pmtiles://';
  78. const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
  79. const POINT_LABEL_LIGHT_LIMIT = 12;
  80. const POINT_LABEL_MEDIUM_LIMIT = 40;
  81. const POINT_LABEL_DENSE_LIMIT = 90;
  82. const POINT_LABEL_HEAVY_LIMIT = 180;
  83. const POINT_CLUSTER_RADIUS = 44;
  84. const POINT_CLUSTER_MAX_ZOOM = 10;
  85. const mapRef = ref<HTMLElement | null>(null);
  86. const mapBoxRef = ref<HTMLElement | null>(null);
  87. const map = ref<MapLibreMap | null>(null);
  88. const mapLoaded = ref(false);
  89. const mapMode = ref<'template' | 'fallback'>('fallback');
  90. const errorMsg = ref('');
  91. let popup: MapLibrePopup | null = null;
  92. let mapLabelRestoreTimer: ReturnType<typeof setTimeout> | null = null;
  93. let pmtilesRegistered = false;
  94. let pmtilesProtocol: Protocol | null = null;
  95. let mapResizeObserver: ResizeObserver | null = null;
  96. const titleText = computed(() => props.title || '通信基站');
  97. const isPointInsideChinaBounds = (lng: number, lat: number) => {
  98. if (lng === 0 && lat === 0) return false;
  99. return (
  100. lng >= CHINA_BOUNDS.minLng &&
  101. lng <= CHINA_BOUNDS.maxLng &&
  102. lat >= CHINA_BOUNDS.minLat &&
  103. lat <= CHINA_BOUNDS.maxLat
  104. );
  105. };
  106. const validPoints = computed<StationPoint[]>(() => {
  107. return (props.stations || [])
  108. .map((item, index) => {
  109. if (!item || typeof item !== 'object') return null;
  110. const longitudeText = String(item.personStationLongitude || '').trim();
  111. const latitudeText = String(item.personStationLatitude || '').trim();
  112. const lng = Number(longitudeText);
  113. const lat = Number(latitudeText);
  114. if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
  115. if (!isPointInsideChinaBounds(lng, lat)) return null;
  116. const regionId = String(item.personRegionId || '').trim();
  117. const cellId = String(item.personCellId || '').trim();
  118. const fallbackName = [regionId, cellId].filter(Boolean).join('-') || '基站位置';
  119. return {
  120. id: `${item.personStationName || ''}||${longitudeText}||${latitudeText}||${index}`,
  121. lng,
  122. lat,
  123. name: String(item.personStationName || '').trim() || fallbackName,
  124. regionId,
  125. cellId,
  126. longitudeText,
  127. latitudeText,
  128. };
  129. })
  130. .filter((item): item is StationPoint => !!item);
  131. });
  132. const ensurePmtiles = () => {
  133. if (pmtilesRegistered) return;
  134. pmtilesProtocol = new Protocol();
  135. maplibregl.addProtocol('pmtiles', pmtilesProtocol.tile);
  136. pmtilesRegistered = true;
  137. };
  138. const ensureOfflinePmtilesArchive = () => {
  139. const filePath = getOfflineMapFilePath('china.pmtiles');
  140. if (!pmtilesProtocol || !filePath) return;
  141. if (pmtilesProtocol.get(filePath)) return;
  142. pmtilesProtocol.add(new PMTiles(new ElectronPmtilesSource(filePath) as any));
  143. };
  144. const getPublicBaseUrl = () => new URL(import.meta.env.BASE_URL || './', window.location.href).toString();
  145. const resolveStyleAssetUrl = (url: string, styleUrl: string) => {
  146. if (ABSOLUTE_URL_RE.test(url)) return toLocalMapUrl(url);
  147. if (url.startsWith('./') || url.startsWith('../')) return new URL(url, styleUrl).toString();
  148. return new URL(url.replace(/^\/+/, ''), getPublicBaseUrl()).toString();
  149. };
  150. const normalizePmtilesSources = (style: Record<string, any>, baseUrl: string, overridePmtilesUrl?: string) => {
  151. const sources = style?.sources;
  152. if (!sources || typeof sources !== 'object') return style;
  153. const normalized = Object.entries(sources).reduce<Record<string, any>>((acc, [key, source]) => {
  154. const sourceItem = source as Record<string, any>;
  155. if (
  156. !sourceItem ||
  157. typeof sourceItem !== 'object' ||
  158. typeof sourceItem.url !== 'string' ||
  159. !sourceItem.url.startsWith(PMTILES_PROTOCOL)
  160. ) {
  161. acc[key] = source;
  162. return acc;
  163. }
  164. const normalizedUrl = overridePmtilesUrl
  165. ? overridePmtilesUrl
  166. : toLocalMapUrl(resolveStyleAssetUrl(sourceItem.url.slice(PMTILES_PROTOCOL.length), baseUrl));
  167. acc[key] = {
  168. ...sourceItem,
  169. url: `${PMTILES_PROTOCOL}${normalizedUrl}`,
  170. };
  171. return acc;
  172. }, {});
  173. return { ...style, sources: normalized };
  174. };
  175. const getFallbackStyle = () => ({
  176. version: 8,
  177. sources: {},
  178. layers: [{ id: 'portrait-station-background', type: 'background', paint: { 'background-color': '#edf3f8' } }],
  179. });
  180. const buildExternalMapStyle = () => {
  181. const offlineMapDir = getOfflineMapDir();
  182. const pmtilesFilePath = getOfflineMapFilePath('china.pmtiles');
  183. if (!offlineMapDir || !pmtilesFilePath) return null;
  184. return {
  185. version: 8,
  186. name: 'portrait-offline-map-external',
  187. sources: {
  188. 'behavior-basemap': {
  189. type: 'vector',
  190. url: `${PMTILES_PROTOCOL}${pmtilesFilePath}`,
  191. },
  192. },
  193. layers: [
  194. {
  195. id: 'behavior-background',
  196. type: 'background',
  197. paint: { 'background-color': '#f7fafc' },
  198. },
  199. {
  200. id: 'behavior-landcover',
  201. type: 'fill',
  202. source: 'behavior-basemap',
  203. 'source-layer': 'landcover',
  204. paint: { 'fill-color': '#e7efe4', 'fill-opacity': 0.8 },
  205. },
  206. {
  207. id: 'behavior-landuse',
  208. type: 'fill',
  209. source: 'behavior-basemap',
  210. 'source-layer': 'landuse',
  211. paint: { 'fill-color': '#eef2e7', 'fill-opacity': 0.72 },
  212. },
  213. {
  214. id: 'behavior-park',
  215. type: 'fill',
  216. source: 'behavior-basemap',
  217. 'source-layer': 'park',
  218. paint: { 'fill-color': '#dcefd4', 'fill-opacity': 0.76 },
  219. },
  220. {
  221. id: 'behavior-water',
  222. type: 'fill',
  223. source: 'behavior-basemap',
  224. 'source-layer': 'water',
  225. paint: { 'fill-color': '#cfe6ff', 'fill-opacity': 0.96 },
  226. },
  227. {
  228. id: 'behavior-waterway',
  229. type: 'line',
  230. source: 'behavior-basemap',
  231. 'source-layer': 'waterway',
  232. paint: { 'line-color': '#9dc8f7', 'line-width': 1.2 },
  233. },
  234. {
  235. id: 'behavior-boundary',
  236. type: 'line',
  237. source: 'behavior-basemap',
  238. 'source-layer': 'boundary',
  239. paint: { 'line-color': '#a7b3c2', 'line-width': 0.8, 'line-opacity': 0.7 },
  240. },
  241. {
  242. id: 'behavior-transportation',
  243. type: 'line',
  244. source: 'behavior-basemap',
  245. 'source-layer': 'transportation',
  246. layout: { 'line-cap': 'round', 'line-join': 'round' },
  247. paint: { 'line-color': '#f3c985', 'line-width': 1.1 },
  248. },
  249. {
  250. id: 'behavior-place-label',
  251. type: 'symbol',
  252. source: 'behavior-basemap',
  253. 'source-layer': 'place',
  254. minzoom: 3,
  255. filter: ['has', 'name'],
  256. layout: {
  257. 'text-field': ['coalesce', ['get', 'name:zh-Hans'], ['get', 'name:zh'], ['get', 'name']],
  258. 'text-font': ['Microsoft YaHei', 'Microsoft JhengHei', 'Noto Sans CJK SC'],
  259. 'text-size': 11,
  260. 'text-anchor': 'top',
  261. 'text-offset': [0, 0.6],
  262. },
  263. paint: {
  264. 'text-color': '#1f2937',
  265. 'text-halo-color': 'rgba(255, 255, 255, 0.98)',
  266. 'text-halo-width': 1.2,
  267. },
  268. },
  269. ],
  270. };
  271. };
  272. const getOfflineMapStylePath = () => getOfflineMapFilePath('behavior-spacetime-style.json');
  273. const readOfflineMapStyle = async () => {
  274. const ipcRenderer = (window as any)?.electron?.ipcRenderer;
  275. const filePath = getOfflineMapStylePath();
  276. if (!ipcRenderer?.invoke || !filePath) return '';
  277. try {
  278. return (await ipcRenderer.invoke('controller/os/readTextFile', { filePath, encoding: 'utf8' })) || '';
  279. } catch {
  280. return '';
  281. }
  282. };
  283. const resolveStyle = async () => {
  284. const custom = (window as PortraitStationWindow).__BEHAVIOR_SPACETIME_STYLE__ || STYLE_PATH;
  285. try {
  286. const offlineMapDir = getOfflineMapDir();
  287. const offlinePmtilesFilePath = getOfflineMapFilePath('china.pmtiles');
  288. const offlineStylePath = getOfflineMapStylePath();
  289. if (offlineMapDir && offlinePmtilesFilePath && offlineStylePath) {
  290. const styleText = await readOfflineMapStyle();
  291. if (styleText) {
  292. const style = JSON.parse(styleText);
  293. mapMode.value = 'template';
  294. errorMsg.value = '';
  295. return normalizePmtilesSources(style as Record<string, any>, offlineStylePath, offlinePmtilesFilePath);
  296. }
  297. }
  298. const externalStyle = buildExternalMapStyle();
  299. if (externalStyle) {
  300. mapMode.value = 'template';
  301. errorMsg.value = '';
  302. return externalStyle;
  303. }
  304. if (custom && typeof custom === 'object') {
  305. mapMode.value = 'template';
  306. errorMsg.value = '';
  307. return normalizePmtilesSources(custom, window.location.href, offlinePmtilesFilePath || undefined);
  308. }
  309. const styleUrl = resolveStyleAssetUrl(String(custom), window.location.href);
  310. const response = await fetch(styleUrl, { cache: 'no-store' });
  311. if (!response.ok) throw new Error(`style request failed: ${response.status}`);
  312. const style = await response.json();
  313. mapMode.value = 'template';
  314. errorMsg.value = '';
  315. return normalizePmtilesSources(
  316. style as Record<string, any>,
  317. response.url || styleUrl,
  318. offlinePmtilesFilePath || undefined,
  319. );
  320. } catch (error) {
  321. errorMsg.value = error instanceof Error ? error.message : 'style error';
  322. }
  323. mapMode.value = 'fallback';
  324. return getFallbackStyle();
  325. };
  326. const toGeoJson = () => ({
  327. type: 'FeatureCollection',
  328. features: validPoints.value.map((item) => ({
  329. type: 'Feature',
  330. geometry: {
  331. type: 'Point',
  332. coordinates: [item.lng, item.lat],
  333. },
  334. properties: {
  335. id: item.id,
  336. name: item.name,
  337. regionId: item.regionId || '-',
  338. cellId: item.cellId || '-',
  339. longitude: item.longitudeText,
  340. latitude: item.latitudeText,
  341. },
  342. })),
  343. });
  344. const createPopupContent = (properties: Record<string, unknown>) => {
  345. const container = document.createElement('div');
  346. container.className = 'portrait-station-map-popup';
  347. const title = document.createElement('div');
  348. title.className = 'portrait-station-map-popup__title';
  349. title.textContent = String(properties.name || '基站位置');
  350. container.appendChild(title);
  351. const entries = [
  352. ['LAC', String(properties.regionId || '-')],
  353. ['CID', String(properties.cellId || '-')],
  354. ['经度', String(properties.longitude || '-')],
  355. ['纬度', String(properties.latitude || '-')],
  356. ];
  357. entries.forEach(([label, value]) => {
  358. const row = document.createElement('div');
  359. row.className = 'portrait-station-map-popup__row';
  360. row.textContent = `${label}:${value}`;
  361. container.appendChild(row);
  362. });
  363. return container;
  364. };
  365. const getPointLabelTextSize = (labelCount: number) => {
  366. if (labelCount > POINT_LABEL_HEAVY_LIMIT) return 10;
  367. if (labelCount > POINT_LABEL_DENSE_LIMIT) return 10.5;
  368. if (labelCount > POINT_LABEL_MEDIUM_LIMIT) return 11;
  369. if (labelCount > POINT_LABEL_LIGHT_LIMIT) return 11.5;
  370. return 11.5;
  371. };
  372. const createMapPinImage = async (fillColor: string, size = 24): Promise<ImageData | null> => {
  373. const svgMarkup = mapPinSvg.replace(/<path\b/, `<path fill="${fillColor}"`);
  374. const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
  375. const imageElement = await new Promise<HTMLImageElement>((resolve, reject) => {
  376. const image = new Image();
  377. image.onload = () => resolve(image);
  378. image.onerror = (error) => reject(error);
  379. image.src = svgDataUrl;
  380. });
  381. const pixelRatio = 2;
  382. const logicalWidth = size;
  383. const logicalHeight = size;
  384. const canvas = document.createElement('canvas');
  385. canvas.width = logicalWidth * pixelRatio;
  386. canvas.height = logicalHeight * pixelRatio;
  387. const ctx = canvas.getContext('2d');
  388. if (!ctx) return null;
  389. ctx.scale(pixelRatio, pixelRatio);
  390. ctx.clearRect(0, 0, logicalWidth, logicalHeight);
  391. ctx.drawImage(imageElement, 0, 0, logicalWidth, logicalHeight);
  392. return ctx.getImageData(0, 0, canvas.width, canvas.height);
  393. };
  394. const registerMapPinImage = async (instance: MapLibreMap) => {
  395. if (instance.hasImage(MAP_PIN_ICON_STATION)) {
  396. instance.removeImage(MAP_PIN_ICON_STATION);
  397. }
  398. const image = await createMapPinImage('#1677ff', 24);
  399. if (!image) return;
  400. instance.addImage(MAP_PIN_ICON_STATION, image, { pixelRatio: 2 });
  401. };
  402. const updateSource = () => {
  403. if (!map.value || !mapLoaded.value) return;
  404. (map.value.getSource(SOURCE_ID) as any)?.setData(toGeoJson() as any);
  405. };
  406. const clearMapLabelRestoreTimer = () => {
  407. if (mapLabelRestoreTimer !== null) {
  408. clearTimeout(mapLabelRestoreTimer);
  409. mapLabelRestoreTimer = null;
  410. }
  411. };
  412. const setMapLabelLayerVisibility = (visible: boolean) => {
  413. if (!map.value || !mapLoaded.value || !map.value.getLayer(POINT_LABEL_LAYER_ID)) return;
  414. map.value.setLayoutProperty(POINT_LABEL_LAYER_ID, 'visibility', visible ? 'visible' : 'none');
  415. };
  416. const syncMapLabelLayerStyle = () => {
  417. if (!map.value || !mapLoaded.value || !map.value.getLayer(POINT_LABEL_LAYER_ID)) return;
  418. map.value.setLayoutProperty(POINT_LABEL_LAYER_ID, 'text-size', getPointLabelTextSize(validPoints.value.length));
  419. };
  420. const syncMapLabelLayerVisibility = () => {
  421. setMapLabelLayerVisibility(validPoints.value.length > 0);
  422. };
  423. const scheduleMapLabelLayerRestore = () => {
  424. clearMapLabelRestoreTimer();
  425. mapLabelRestoreTimer = setTimeout(() => {
  426. mapLabelRestoreTimer = null;
  427. syncMapLabelLayerVisibility();
  428. }, 120);
  429. };
  430. const openPointPopup = (instance: MapLibreMap, feature?: maplibregl.MapGeoJSONFeature) => {
  431. const geometry = feature?.geometry as any;
  432. const coordinates = geometry?.coordinates;
  433. if (!feature?.properties || !Array.isArray(coordinates) || coordinates.length < 2) return;
  434. popup?.remove();
  435. popup = new maplibregl.Popup({
  436. closeButton: false,
  437. offset: 18,
  438. maxWidth: '280px',
  439. })
  440. .setLngLat([Number(coordinates[0]), Number(coordinates[1])])
  441. .setDOMContent(createPopupContent(feature.properties as Record<string, unknown>))
  442. .addTo(instance);
  443. };
  444. const fitMap = (animate = true) => {
  445. if (!map.value || !mapLoaded.value) return;
  446. if (!validPoints.value.length) {
  447. map.value.easeTo({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, duration: animate ? 500 : 0 });
  448. return;
  449. }
  450. if (validPoints.value.length === 1) {
  451. const item = validPoints.value[0];
  452. map.value.easeTo({ center: [item.lng, item.lat], zoom: 13.8, duration: animate ? 500 : 0 });
  453. return;
  454. }
  455. const bounds = new maplibregl.LngLatBounds();
  456. validPoints.value.forEach((item) => bounds.extend([item.lng, item.lat]));
  457. map.value.fitBounds(bounds, { padding: 64, maxZoom: 13.8, duration: animate ? 700 : 0 });
  458. };
  459. const bindMapEvents = (instance: MapLibreMap) => {
  460. const handleClusterClick = (event: maplibregl.MapLayerMouseEvent) => {
  461. const clusterFeature = event.features?.[0] as any;
  462. const clusterId = clusterFeature?.properties?.cluster_id;
  463. const pointCount = Number(clusterFeature?.properties?.point_count || 0);
  464. const clusterCoordinates = clusterFeature?.geometry?.coordinates as [number, number] | undefined;
  465. if (clusterId === undefined || !clusterCoordinates) return;
  466. const source = instance.getSource(SOURCE_ID) as any;
  467. if (!source?.getClusterExpansionZoom || !source?.getClusterLeaves) return;
  468. void (async () => {
  469. try {
  470. const leaves = (await source.getClusterLeaves(clusterId, Math.max(pointCount, 1), 0)) as Array<any>;
  471. const coordinates = leaves
  472. .map((feature) => feature?.geometry?.coordinates)
  473. .filter(
  474. (coords): coords is [number, number] =>
  475. Array.isArray(coords) &&
  476. coords.length >= 2 &&
  477. typeof coords[0] === 'number' &&
  478. typeof coords[1] === 'number',
  479. );
  480. if (coordinates.length > 1) {
  481. const bounds = new maplibregl.LngLatBounds();
  482. coordinates.forEach((coords) => bounds.extend(coords));
  483. instance.fitBounds(bounds, {
  484. padding: 96,
  485. duration: 420,
  486. maxZoom: MAP_MAX_ZOOM,
  487. });
  488. return;
  489. }
  490. const zoom = await source.getClusterExpansionZoom(clusterId);
  491. instance.easeTo({
  492. center: coordinates[0] || clusterCoordinates,
  493. zoom: Math.min(Math.max(zoom, POINT_CLUSTER_MAX_ZOOM + 1.2), MAP_MAX_ZOOM),
  494. duration: 420,
  495. });
  496. } catch {
  497. const zoom = await source.getClusterExpansionZoom(clusterId);
  498. instance.easeTo({
  499. center: clusterCoordinates,
  500. zoom: Math.min(Math.max(zoom, POINT_CLUSTER_MAX_ZOOM + 1.2), MAP_MAX_ZOOM),
  501. duration: 420,
  502. });
  503. }
  504. })();
  505. };
  506. instance.on('movestart', () => {
  507. clearMapLabelRestoreTimer();
  508. setMapLabelLayerVisibility(false);
  509. });
  510. instance.on('moveend', () => {
  511. scheduleMapLabelLayerRestore();
  512. });
  513. instance.on('click', CLUSTER_LAYER_ID, handleClusterClick);
  514. instance.on('click', CLUSTER_COUNT_LAYER_ID, handleClusterClick);
  515. instance.on('click', POINT_LAYER_ID, (event) => {
  516. openPointPopup(instance, event.features?.[0]);
  517. });
  518. instance.on('click', POINT_LABEL_LAYER_ID, (event) => {
  519. openPointPopup(instance, event.features?.[0]);
  520. });
  521. instance.on('mouseenter', CLUSTER_LAYER_ID, () => {
  522. instance.getCanvas().style.cursor = 'pointer';
  523. });
  524. instance.on('mouseleave', CLUSTER_LAYER_ID, () => {
  525. instance.getCanvas().style.cursor = '';
  526. });
  527. instance.on('mouseenter', CLUSTER_COUNT_LAYER_ID, () => {
  528. instance.getCanvas().style.cursor = 'pointer';
  529. });
  530. instance.on('mouseleave', CLUSTER_COUNT_LAYER_ID, () => {
  531. instance.getCanvas().style.cursor = '';
  532. });
  533. instance.on('mouseenter', POINT_LAYER_ID, () => {
  534. instance.getCanvas().style.cursor = 'pointer';
  535. });
  536. instance.on('mouseleave', POINT_LAYER_ID, () => {
  537. instance.getCanvas().style.cursor = '';
  538. });
  539. instance.on('mouseenter', POINT_LABEL_LAYER_ID, () => {
  540. instance.getCanvas().style.cursor = 'pointer';
  541. });
  542. instance.on('mouseleave', POINT_LABEL_LAYER_ID, () => {
  543. instance.getCanvas().style.cursor = '';
  544. });
  545. };
  546. const initMap = async () => {
  547. if (!mapRef.value || map.value) return;
  548. ensurePmtiles();
  549. ensureOfflinePmtilesArchive();
  550. const style = await resolveStyle();
  551. const instance = new maplibregl.Map({
  552. container: mapRef.value,
  553. style: style as any,
  554. center: DEFAULT_CENTER,
  555. zoom: DEFAULT_ZOOM,
  556. minZoom: 4,
  557. maxZoom: MAP_MAX_ZOOM,
  558. attributionControl: false,
  559. crossSourceCollisions: false,
  560. fadeDuration: 0,
  561. renderWorldCopies: false,
  562. });
  563. map.value = instance;
  564. instance.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
  565. instance.on('load', async () => {
  566. mapLoaded.value = true;
  567. await registerMapPinImage(instance);
  568. instance.addSource(SOURCE_ID, {
  569. type: 'geojson',
  570. data: toGeoJson() as any,
  571. cluster: true,
  572. clusterRadius: POINT_CLUSTER_RADIUS,
  573. clusterMaxZoom: POINT_CLUSTER_MAX_ZOOM,
  574. });
  575. instance.addLayer({
  576. id: CLUSTER_LAYER_ID,
  577. type: 'circle',
  578. source: SOURCE_ID,
  579. filter: ['has', 'point_count'],
  580. paint: {
  581. 'circle-radius': ['step', ['get', 'point_count'], 17, 6, 20, 12, 23, 20, 26],
  582. 'circle-color': '#2563eb',
  583. 'circle-stroke-color': '#ffffff',
  584. 'circle-stroke-width': 2.5,
  585. 'circle-opacity': 0.96,
  586. },
  587. });
  588. instance.addLayer({
  589. id: CLUSTER_COUNT_LAYER_ID,
  590. type: 'symbol',
  591. source: SOURCE_ID,
  592. filter: ['has', 'point_count'],
  593. layout: {
  594. 'text-field': ['to-string', ['get', 'point_count']],
  595. 'text-size': 11,
  596. 'text-font': ['Microsoft YaHei', 'Microsoft JhengHei', 'Noto Sans CJK SC'],
  597. 'text-allow-overlap': true,
  598. 'text-ignore-placement': true,
  599. },
  600. paint: {
  601. 'text-color': '#ffffff',
  602. },
  603. });
  604. instance.addLayer({
  605. id: POINT_LAYER_ID,
  606. type: 'symbol',
  607. source: SOURCE_ID,
  608. filter: ['!', ['has', 'point_count']],
  609. layout: {
  610. 'icon-image': MAP_PIN_ICON_STATION,
  611. 'icon-size': ['interpolate', ['linear'], ['zoom'], 4, 1.52, 10.6, 1.76, 14, 1.92],
  612. 'icon-anchor': 'bottom',
  613. 'icon-allow-overlap': true,
  614. 'icon-ignore-placement': true,
  615. },
  616. });
  617. instance.addLayer({
  618. id: POINT_LABEL_LAYER_ID,
  619. type: 'symbol',
  620. source: SOURCE_ID,
  621. filter: ['!', ['has', 'point_count']],
  622. layout: {
  623. 'text-field': ['coalesce', ['get', 'name'], ''],
  624. 'text-font': ['Microsoft YaHei', 'Microsoft JhengHei', 'Noto Sans CJK SC'],
  625. 'text-size': getPointLabelTextSize(validPoints.value.length),
  626. 'text-offset': [0, 0.58],
  627. 'text-anchor': 'top',
  628. 'text-justify': 'center',
  629. 'text-line-height': 1.22,
  630. 'text-max-width': 18,
  631. 'text-allow-overlap': true,
  632. 'text-ignore-placement': true,
  633. 'text-optional': true,
  634. 'text-padding': 2,
  635. },
  636. paint: {
  637. 'text-color': '#0f172a',
  638. 'text-halo-color': 'rgba(255, 255, 255, 0.96)',
  639. 'text-halo-width': 1.5,
  640. },
  641. });
  642. bindMapEvents(instance);
  643. fitMap(false);
  644. syncMapLabelLayerVisibility();
  645. setTimeout(() => instance.resize(), 0);
  646. });
  647. instance.on('error', (event) => {
  648. errorMsg.value = event.error?.message || 'map error';
  649. mapMode.value = 'fallback';
  650. });
  651. };
  652. const syncMap = async () => {
  653. if (!props.open) return;
  654. await nextTick();
  655. await initMap();
  656. if (!map.value) return;
  657. map.value.resize();
  658. updateSource();
  659. syncMapLabelLayerStyle();
  660. syncMapLabelLayerVisibility();
  661. fitMap(false);
  662. };
  663. const observeMapContainer = () => {
  664. if (typeof ResizeObserver === 'undefined' || mapResizeObserver || !mapBoxRef.value) return;
  665. mapResizeObserver = new ResizeObserver(() => {
  666. if (!props.open || !map.value) return;
  667. map.value.resize();
  668. });
  669. mapResizeObserver.observe(mapBoxRef.value);
  670. };
  671. const unobserveMapContainer = () => {
  672. mapResizeObserver?.disconnect();
  673. mapResizeObserver = null;
  674. };
  675. const recreateMap = async () => {
  676. mapLoaded.value = false;
  677. errorMsg.value = '';
  678. clearMapLabelRestoreTimer();
  679. popup?.remove();
  680. popup = null;
  681. map.value?.remove();
  682. map.value = null;
  683. await nextTick();
  684. if (props.open) {
  685. await syncMap();
  686. }
  687. };
  688. const handleOfflineMapDirChanged = () => {
  689. void recreateMap();
  690. };
  691. watch(
  692. () => props.open,
  693. (open) => {
  694. if (!open) {
  695. unobserveMapContainer();
  696. popup?.remove();
  697. popup = null;
  698. return;
  699. }
  700. observeMapContainer();
  701. void syncMap();
  702. },
  703. );
  704. watch(
  705. validPoints,
  706. () => {
  707. if (!props.open) return;
  708. updateSource();
  709. syncMapLabelLayerStyle();
  710. syncMapLabelLayerVisibility();
  711. fitMap(false);
  712. },
  713. { deep: true },
  714. );
  715. onMounted(() => {
  716. window.addEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, handleOfflineMapDirChanged);
  717. observeMapContainer();
  718. });
  719. onBeforeUnmount(() => {
  720. window.removeEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, handleOfflineMapDirChanged);
  721. unobserveMapContainer();
  722. clearMapLabelRestoreTimer();
  723. popup?.remove();
  724. popup = null;
  725. map.value?.remove();
  726. map.value = null;
  727. mapLoaded.value = false;
  728. });
  729. </script>
  730. <style lang="less">
  731. @prefix-cls: ~'jeesite-portrait-station-map-modal';
  732. .portrait-station-map-modal-wrap {
  733. .ant-modal-content {
  734. display: flex;
  735. flex-direction: column;
  736. max-height: calc(100vh - 32px);
  737. }
  738. .ant-modal-body {
  739. flex: 1 1 auto;
  740. min-height: 0;
  741. padding: 20px;
  742. }
  743. }
  744. .@{prefix-cls}.station-map-modal {
  745. display: flex;
  746. flex-direction: column;
  747. height: calc(100vh - 160px);
  748. min-height: 620px;
  749. .station-map-modal__map-box {
  750. flex: 1 1 auto;
  751. min-height: 0;
  752. position: relative;
  753. overflow: hidden;
  754. background: #edf3f8;
  755. border: 1px solid #e5e7eb;
  756. border-radius: 14px;
  757. }
  758. .station-map-modal__map {
  759. width: 100%;
  760. height: 100%;
  761. }
  762. .station-map-modal__empty {
  763. position: absolute;
  764. inset: 0;
  765. z-index: 2;
  766. display: flex;
  767. align-items: center;
  768. justify-content: center;
  769. pointer-events: none;
  770. background: rgb(248 250 252 / 68%);
  771. font-size: 14px;
  772. color: #64748b;
  773. }
  774. .station-map-modal__error {
  775. position: absolute;
  776. top: 14px;
  777. left: 14px;
  778. z-index: 3;
  779. max-width: calc(100% - 120px);
  780. padding: 6px 10px;
  781. font-size: 12px;
  782. line-height: 18px;
  783. color: #b45309;
  784. background: rgb(255 247 237 / 94%);
  785. border: 1px solid #fdba74;
  786. border-radius: 10px;
  787. box-shadow: 0 6px 14px rgb(15 23 42 / 8%);
  788. }
  789. .maplibregl-ctrl-top-right {
  790. top: 12px;
  791. right: 12px;
  792. }
  793. .maplibregl-ctrl-group {
  794. overflow: hidden;
  795. border-radius: 12px;
  796. box-shadow: 0 10px 18px rgb(15 23 42 / 8%);
  797. }
  798. }
  799. .portrait-station-map-popup {
  800. min-width: 180px;
  801. padding: 2px 2px 0;
  802. font-size: 12px;
  803. line-height: 1.7;
  804. color: #475569;
  805. }
  806. .portrait-station-map-popup__title {
  807. margin-bottom: 4px;
  808. font-size: 13px;
  809. font-weight: 600;
  810. line-height: 20px;
  811. color: #0f172a;
  812. }
  813. .portrait-station-map-popup__row + .portrait-station-map-popup__row {
  814. margin-top: 2px;
  815. }
  816. </style>