| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584 |
- <template>
- <div :class="prefixCls" class="base-station-query-map">
- <div ref="mapBoxRef" class="base-station-query-map__box">
- <div ref="mapRef" class="base-station-query-map__canvas"></div>
- <div v-if="errorMsg" class="base-station-query-map__error">{{ errorMsg }}</div>
- <div v-if="!points.length" class="base-station-query-map__empty">暂无基站坐标数据</div>
- </div>
- </div>
- </template>
- <script lang="ts" setup>
- import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue';
- import { useDesign } from '@jeesite/core/hooks/web/useDesign';
- import {
- ElectronPmtilesSource,
- getOfflineMapDir,
- getOfflineMapFilePath,
- OFFLINE_MAP_DIR_CHANGED_EVENT,
- toLocalMapUrl,
- } from '@jeesite/core/utils/map/offlineMap';
- import maplibregl from 'maplibre-gl';
- import type { Map as MapLibreMap, Popup as MapLibrePopup } from 'maplibre-gl';
- import 'maplibre-gl/dist/maplibre-gl.css';
- import { PMTiles, Protocol } from 'pmtiles';
- import mapPinSvg from '../../behaviorSpacetime/assets/location_on_baseline.svg?raw';
- export interface BaseStationMapPoint {
- id: string;
- lng: number;
- lat: number;
- title: string;
- address?: string;
- regionId?: string;
- cellId?: string;
- longitudeText?: string;
- latitudeText?: string;
- range?: string;
- operatorName?: string;
- occurTime?: string;
- personNo?: string;
- }
- type BaseStationWindow = Window & {
- __BEHAVIOR_SPACETIME_STYLE__?: string | Record<string, any>;
- };
- const props = withDefaults(
- defineProps<{
- points?: BaseStationMapPoint[];
- }>(),
- {
- points: () => [],
- },
- );
- const { prefixCls } = useDesign('base-station-query-map');
- const STYLE_PATH = `${import.meta.env.BASE_URL}resource/map/behavior-spacetime-style.json`;
- const DEFAULT_CENTER: [number, number] = [104.0668, 30.5728];
- const DEFAULT_ZOOM = 10.2;
- const MAP_MAX_ZOOM = 14;
- const CHINA_BOUNDS = {
- minLng: 73.41788,
- maxLng: 134.803619,
- minLat: 14.27437,
- maxLat: 53.6555899,
- };
- const SOURCE_ID = 'base-station-query-source';
- const POINT_LAYER_ID = 'base-station-query-points';
- const MAP_PIN_ICON = 'base-station-query-pin';
- const PMTILES_PROTOCOL = 'pmtiles://';
- const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
- const mapRef = ref<HTMLElement | null>(null);
- const mapBoxRef = ref<HTMLElement | null>(null);
- const map = ref<MapLibreMap | null>(null);
- const mapLoaded = ref(false);
- const errorMsg = ref('');
- const validPoints = computed(() =>
- (props.points || []).filter(
- (item) =>
- Number.isFinite(Number(item?.lng)) &&
- Number.isFinite(Number(item?.lat)) &&
- Number(item.lng) >= CHINA_BOUNDS.minLng &&
- Number(item.lng) <= CHINA_BOUNDS.maxLng &&
- Number(item.lat) >= CHINA_BOUNDS.minLat &&
- Number(item.lat) <= CHINA_BOUNDS.maxLat,
- ),
- );
- let popup: MapLibrePopup | null = null;
- let pmtilesRegistered = false;
- let pmtilesProtocol: Protocol | null = null;
- let mapResizeObserver: ResizeObserver | null = null;
- const ensurePmtiles = () => {
- if (pmtilesRegistered) return;
- pmtilesProtocol = new Protocol();
- maplibregl.addProtocol('pmtiles', pmtilesProtocol.tile);
- pmtilesRegistered = true;
- };
- const ensureOfflinePmtilesArchive = () => {
- const filePath = getOfflineMapFilePath('china.pmtiles');
- if (!pmtilesProtocol || !filePath) return;
- if (pmtilesProtocol.get(filePath)) return;
- pmtilesProtocol.add(new PMTiles(new ElectronPmtilesSource(filePath) as any));
- };
- const getPublicBaseUrl = () => new URL(import.meta.env.BASE_URL || './', window.location.href).toString();
- const resolveStyleAssetUrl = (url: string, styleUrl: string) => {
- if (ABSOLUTE_URL_RE.test(url)) return toLocalMapUrl(url);
- if (url.startsWith('./') || url.startsWith('../')) return new URL(url, styleUrl).toString();
- return new URL(url.replace(/^\/+/, ''), getPublicBaseUrl()).toString();
- };
- const normalizePmtilesSources = (style: Record<string, any>, baseUrl: string, overridePmtilesUrl?: string) => {
- const sources = style?.sources;
- if (!sources || typeof sources !== 'object') return style;
- const normalizedSources = Object.entries(sources).reduce<Record<string, any>>((acc, [key, source]) => {
- const sourceItem = source as Record<string, any>;
- if (
- !sourceItem ||
- typeof sourceItem !== 'object' ||
- typeof sourceItem.url !== 'string' ||
- !sourceItem.url.startsWith(PMTILES_PROTOCOL)
- ) {
- acc[key] = source;
- return acc;
- }
- const normalizedUrl = overridePmtilesUrl
- ? overridePmtilesUrl
- : toLocalMapUrl(resolveStyleAssetUrl(sourceItem.url.slice(PMTILES_PROTOCOL.length), baseUrl));
- acc[key] = {
- ...sourceItem,
- url: `${PMTILES_PROTOCOL}${normalizedUrl}`,
- };
- return acc;
- }, {});
- return { ...style, sources: normalizedSources };
- };
- const getFallbackStyle = () => ({
- version: 8,
- sources: {},
- layers: [{ id: 'base-station-query-background', type: 'background', paint: { 'background-color': '#edf3f8' } }],
- });
- const buildExternalMapStyle = () => {
- const offlineMapDir = getOfflineMapDir();
- const pmtilesFilePath = getOfflineMapFilePath('china.pmtiles');
- if (!offlineMapDir || !pmtilesFilePath) return null;
- return {
- version: 8,
- name: 'base-station-query-offline-style',
- sources: {
- 'behavior-basemap': {
- type: 'vector',
- url: `${PMTILES_PROTOCOL}${pmtilesFilePath}`,
- },
- },
- layers: [
- {
- id: 'behavior-background',
- type: 'background',
- paint: { 'background-color': '#f7fafc' },
- },
- {
- id: 'behavior-landcover',
- type: 'fill',
- source: 'behavior-basemap',
- 'source-layer': 'landcover',
- paint: { 'fill-color': '#e7efe4', 'fill-opacity': 0.8 },
- },
- {
- id: 'behavior-landuse',
- type: 'fill',
- source: 'behavior-basemap',
- 'source-layer': 'landuse',
- paint: { 'fill-color': '#eef2e7', 'fill-opacity': 0.72 },
- },
- {
- id: 'behavior-park',
- type: 'fill',
- source: 'behavior-basemap',
- 'source-layer': 'park',
- paint: { 'fill-color': '#dcefd4', 'fill-opacity': 0.76 },
- },
- {
- id: 'behavior-water',
- type: 'fill',
- source: 'behavior-basemap',
- 'source-layer': 'water',
- paint: { 'fill-color': '#cfe6ff', 'fill-opacity': 0.96 },
- },
- {
- id: 'behavior-waterway',
- type: 'line',
- source: 'behavior-basemap',
- 'source-layer': 'waterway',
- paint: { 'line-color': '#9dc8f7', 'line-width': 1.2 },
- },
- {
- id: 'behavior-boundary',
- type: 'line',
- source: 'behavior-basemap',
- 'source-layer': 'boundary',
- paint: { 'line-color': '#a7b3c2', 'line-width': 0.8, 'line-opacity': 0.7 },
- },
- {
- id: 'behavior-transportation',
- type: 'line',
- source: 'behavior-basemap',
- 'source-layer': 'transportation',
- layout: { 'line-cap': 'round', 'line-join': 'round' },
- paint: { 'line-color': '#f3c985', 'line-width': 1.1 },
- },
- ],
- };
- };
- const getOfflineMapStylePath = () => getOfflineMapFilePath('behavior-spacetime-style.json');
- const readOfflineMapStyle = async () => {
- const ipcRenderer = (window as any)?.electron?.ipcRenderer;
- const filePath = getOfflineMapStylePath();
- if (!ipcRenderer?.invoke || !filePath) return '';
- try {
- return (await ipcRenderer.invoke('controller/os/readTextFile', { filePath, encoding: 'utf8' })) || '';
- } catch {
- return '';
- }
- };
- const resolveStyle = async () => {
- const customStyle = (window as BaseStationWindow).__BEHAVIOR_SPACETIME_STYLE__ || STYLE_PATH;
- try {
- const offlinePmtilesFilePath = getOfflineMapFilePath('china.pmtiles');
- const offlineStylePath = getOfflineMapStylePath();
- if (getOfflineMapDir() && offlinePmtilesFilePath && offlineStylePath) {
- const styleText = await readOfflineMapStyle();
- if (styleText) {
- return normalizePmtilesSources(JSON.parse(styleText), offlineStylePath, offlinePmtilesFilePath);
- }
- }
- const externalStyle = buildExternalMapStyle();
- if (externalStyle) {
- return externalStyle;
- }
- if (customStyle && typeof customStyle === 'object') {
- return normalizePmtilesSources(customStyle, window.location.href, offlinePmtilesFilePath || undefined);
- }
- const styleUrl = resolveStyleAssetUrl(String(customStyle), window.location.href);
- const response = await fetch(styleUrl, { cache: 'no-store' });
- if (!response.ok) throw new Error(`style request failed: ${response.status}`);
- const style = await response.json();
- return normalizePmtilesSources(style, response.url || styleUrl, offlinePmtilesFilePath || undefined);
- } catch (error) {
- errorMsg.value = error instanceof Error ? error.message : 'map style error';
- return getFallbackStyle();
- }
- };
- const toGeoJson = () => ({
- type: 'FeatureCollection',
- features: validPoints.value.map((item) => ({
- type: 'Feature',
- geometry: {
- type: 'Point',
- coordinates: [Number(item.lng), Number(item.lat)],
- },
- properties: {
- id: item.id,
- title: item.title,
- address: item.address || '-',
- regionId: item.regionId || '-',
- cellId: item.cellId || '-',
- longitude: item.longitudeText || String(item.lng),
- latitude: item.latitudeText || String(item.lat),
- range: item.range || '-',
- operatorName: item.operatorName || '-',
- occurTime: item.occurTime || '-',
- personNo: item.personNo || '-',
- },
- })),
- });
- const createPopupContent = (properties: Record<string, unknown>) => {
- const container = document.createElement('div');
- container.className = 'base-station-query-map-popup';
- const title = document.createElement('div');
- title.className = 'base-station-query-map-popup__title';
- title.textContent = String(properties.title || '基站位置');
- container.appendChild(title);
- const rows = [
- ['LAC', String(properties.regionId || '-')],
- ['CID', String(properties.cellId || '-')],
- ['经度', String(properties.longitude || '-')],
- ['纬度', String(properties.latitude || '-')],
- ];
- rows.forEach(([label, value]) => {
- const row = document.createElement('div');
- row.className = 'base-station-query-map-popup__row';
- row.textContent = `${label}:${value}`;
- container.appendChild(row);
- });
- return container;
- };
- const createMapPinImage = async (fillColor: string, size = 24): Promise<ImageData | null> => {
- const svgMarkup = mapPinSvg.replace(/<path\b/, `<path fill="${fillColor}"`);
- const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
- const imageElement = await new Promise<HTMLImageElement>((resolve, reject) => {
- const image = new Image();
- image.onload = () => resolve(image);
- image.onerror = (error) => reject(error);
- image.src = svgDataUrl;
- });
- const pixelRatio = 2;
- const logicalWidth = size;
- const logicalHeight = size;
- const canvas = document.createElement('canvas');
- canvas.width = logicalWidth * pixelRatio;
- canvas.height = logicalHeight * pixelRatio;
- const ctx = canvas.getContext('2d');
- if (!ctx) return null;
- ctx.scale(pixelRatio, pixelRatio);
- ctx.clearRect(0, 0, logicalWidth, logicalHeight);
- ctx.drawImage(imageElement, 0, 0, logicalWidth, logicalHeight);
- return ctx.getImageData(0, 0, canvas.width, canvas.height);
- };
- const registerPinImage = async (instance: MapLibreMap) => {
- if (instance.hasImage(MAP_PIN_ICON)) {
- instance.removeImage(MAP_PIN_ICON);
- }
- const image = await createMapPinImage('#1677ff', 24);
- if (!image) return;
- instance.addImage(MAP_PIN_ICON, image, { pixelRatio: 2 });
- };
- const updateSource = () => {
- if (!map.value || !mapLoaded.value) return;
- (map.value.getSource(SOURCE_ID) as any)?.setData(toGeoJson() as any);
- };
- const openPointPopup = (instance: MapLibreMap, feature?: maplibregl.MapGeoJSONFeature) => {
- const geometry = feature?.geometry as any;
- const coordinates = geometry?.coordinates;
- if (!feature?.properties || !Array.isArray(coordinates) || coordinates.length < 2) return;
- popup?.remove();
- popup = new maplibregl.Popup({ closeButton: false, offset: 18, maxWidth: '320px' })
- .setLngLat([Number(coordinates[0]), Number(coordinates[1])])
- .setDOMContent(createPopupContent(feature.properties as Record<string, unknown>))
- .addTo(instance);
- };
- const fitMap = (animate = true) => {
- if (!map.value || !mapLoaded.value) return;
- if (!validPoints.value.length) {
- map.value.easeTo({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, duration: animate ? 500 : 0 });
- return;
- }
- if (validPoints.value.length === 1) {
- const point = validPoints.value[0];
- map.value.easeTo({ center: [Number(point.lng), Number(point.lat)], zoom: 13.8, duration: animate ? 500 : 0 });
- return;
- }
- const bounds = new maplibregl.LngLatBounds();
- validPoints.value.forEach((point) => bounds.extend([Number(point.lng), Number(point.lat)]));
- map.value.fitBounds(bounds, { padding: 64, maxZoom: 13.8, duration: animate ? 700 : 0 });
- };
- const bindMapEvents = (instance: MapLibreMap) => {
- instance.on('click', POINT_LAYER_ID, (event) => {
- openPointPopup(instance, event.features?.[0]);
- });
- instance.on('mouseenter', POINT_LAYER_ID, () => {
- instance.getCanvas().style.cursor = 'pointer';
- });
- instance.on('mouseleave', POINT_LAYER_ID, () => {
- instance.getCanvas().style.cursor = '';
- });
- };
- const initMap = async () => {
- if (!mapRef.value || map.value) return;
- ensurePmtiles();
- ensureOfflinePmtilesArchive();
- const style = await resolveStyle();
- const instance = new maplibregl.Map({
- container: mapRef.value,
- style: style as any,
- center: DEFAULT_CENTER,
- zoom: DEFAULT_ZOOM,
- minZoom: 3,
- maxZoom: MAP_MAX_ZOOM,
- attributionControl: false,
- renderWorldCopies: false,
- });
- map.value = instance;
- instance.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
- instance.on('load', async () => {
- mapLoaded.value = true;
- await registerPinImage(instance);
- instance.addSource(SOURCE_ID, {
- type: 'geojson',
- data: toGeoJson() as any,
- });
- instance.addLayer({
- id: POINT_LAYER_ID,
- type: 'symbol',
- source: SOURCE_ID,
- layout: {
- 'icon-image': MAP_PIN_ICON,
- 'icon-size': ['interpolate', ['linear'], ['zoom'], 4, 0.76, 10.6, 0.88, 14, 0.96],
- 'icon-anchor': 'bottom',
- 'icon-allow-overlap': true,
- 'icon-ignore-placement': true,
- },
- });
- bindMapEvents(instance);
- fitMap(false);
- setTimeout(() => instance.resize(), 0);
- });
- instance.on('error', (event) => {
- errorMsg.value = event.error?.message || 'map error';
- });
- };
- const ensureMapReady = async () => {
- await nextTick();
- await initMap();
- if (!map.value) return;
- map.value.resize();
- updateSource();
- fitMap(false);
- };
- const observeMapContainer = () => {
- if (typeof ResizeObserver === 'undefined' || mapResizeObserver || !mapBoxRef.value) return;
- mapResizeObserver = new ResizeObserver(() => {
- map.value?.resize();
- });
- mapResizeObserver.observe(mapBoxRef.value);
- };
- const unobserveMapContainer = () => {
- mapResizeObserver?.disconnect();
- mapResizeObserver = null;
- };
- const recreateMap = async () => {
- popup?.remove();
- popup = null;
- mapLoaded.value = false;
- errorMsg.value = '';
- map.value?.remove();
- map.value = null;
- await ensureMapReady();
- };
- watch(
- validPoints,
- () => {
- updateSource();
- fitMap(false);
- },
- { deep: true },
- );
- onMounted(() => {
- window.addEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, recreateMap);
- observeMapContainer();
- void ensureMapReady();
- });
- onActivated(() => {
- map.value?.resize();
- });
- onBeforeUnmount(() => {
- window.removeEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, recreateMap);
- unobserveMapContainer();
- popup?.remove();
- popup = null;
- map.value?.remove();
- map.value = null;
- mapLoaded.value = false;
- });
- </script>
- <style lang="less">
- @prefix-cls: ~'jeesite-base-station-query-map';
- .@{prefix-cls}.base-station-query-map {
- display: flex;
- flex: 1 1 auto;
- min-height: 0;
- .base-station-query-map__box {
- position: relative;
- flex: 1 1 auto;
- min-height: 0;
- overflow: hidden;
- background: #edf3f8;
- border: 1px solid #e5e7eb;
- border-radius: 10px;
- }
- .base-station-query-map__canvas {
- width: 100%;
- height: 100%;
- }
- .base-station-query-map__empty {
- position: absolute;
- inset: 0;
- z-index: 2;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgb(248 250 252 / 68%);
- color: #64748b;
- font-size: 14px;
- pointer-events: none;
- }
- .base-station-query-map__error {
- position: absolute;
- top: 14px;
- left: 14px;
- z-index: 3;
- max-width: calc(100% - 120px);
- padding: 6px 10px;
- color: #b45309;
- font-size: 12px;
- line-height: 18px;
- background: rgb(255 247 237 / 94%);
- border: 1px solid #fdba74;
- border-radius: 10px;
- box-shadow: 0 6px 14px rgb(15 23 42 / 8%);
- }
- .maplibregl-ctrl-top-right {
- top: 12px;
- right: 12px;
- }
- .maplibregl-ctrl-group {
- overflow: hidden;
- border-radius: 12px;
- box-shadow: 0 10px 18px rgb(15 23 42 / 8%);
- }
- }
- .base-station-query-map-popup {
- min-width: 220px;
- padding: 2px 2px 0;
- font-size: 12px;
- line-height: 1.7;
- color: #475569;
- }
- .base-station-query-map-popup__title {
- margin-bottom: 4px;
- color: #0f172a;
- font-size: 13px;
- font-weight: 600;
- line-height: 20px;
- }
- .base-station-query-map-popup__row + .base-station-query-map-popup__row {
- margin-top: 2px;
- }
- </style>
|