| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875 |
- <template>
- <BasicModal
- :open="open"
- :title="titleText"
- :showOkBtn="false"
- :showCancelBtn="false"
- :width="1100"
- :centered="true"
- wrapClassName="portrait-station-map-modal-wrap"
- @update:open="(value) => emit('update:open', value)"
- @cancel="emit('update:open', false)"
- >
- <div :class="prefixCls" class="station-map-modal">
- <div ref="mapBoxRef" class="station-map-modal__map-box">
- <div ref="mapRef" class="station-map-modal__map"></div>
- <div v-if="errorMsg" class="station-map-modal__error">{{ errorMsg }}</div>
- <div v-if="!validPoints.length" class="station-map-modal__empty">暂无可展示的基站坐标信息</div>
- </div>
- </div>
- </BasicModal>
- </template>
- <script lang="ts" setup>
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
- import { BasicModal } from '@jeesite/core/components/Modal';
- 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 type { CallStatStationDTO } from '../../../../person/api/types/dto';
- 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 '../../../../trans/views/behaviorSpacetime/assets/location_on_baseline.svg?raw';
- interface StationPoint {
- id: string;
- lng: number;
- lat: number;
- name: string;
- regionId: string;
- cellId: string;
- longitudeText: string;
- latitudeText: string;
- }
- type PortraitStationWindow = Window & {
- __BEHAVIOR_SPACETIME_STYLE__?: string | Record<string, any>;
- };
- const props = defineProps<{
- open: boolean;
- title?: string;
- stations: CallStatStationDTO[];
- }>();
- const emit = defineEmits<{
- (e: 'update:open', value: boolean): void;
- }>();
- const { prefixCls } = useDesign('portrait-station-map-modal');
- 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 = 'portrait-station-source';
- const CLUSTER_LAYER_ID = 'portrait-station-clusters';
- const CLUSTER_COUNT_LAYER_ID = 'portrait-station-cluster-count';
- const POINT_LAYER_ID = 'portrait-station-points';
- const POINT_LABEL_LAYER_ID = 'portrait-station-point-labels';
- const MAP_PIN_ICON_STATION = 'portrait-station-pin';
- const PMTILES_PROTOCOL = 'pmtiles://';
- const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
- const POINT_LABEL_LIGHT_LIMIT = 12;
- const POINT_LABEL_MEDIUM_LIMIT = 40;
- const POINT_LABEL_DENSE_LIMIT = 90;
- const POINT_LABEL_HEAVY_LIMIT = 180;
- const POINT_CLUSTER_RADIUS = 44;
- const POINT_CLUSTER_MAX_ZOOM = 10;
- const mapRef = ref<HTMLElement | null>(null);
- const mapBoxRef = ref<HTMLElement | null>(null);
- const map = ref<MapLibreMap | null>(null);
- const mapLoaded = ref(false);
- const mapMode = ref<'template' | 'fallback'>('fallback');
- const errorMsg = ref('');
- let popup: MapLibrePopup | null = null;
- let mapLabelRestoreTimer: ReturnType<typeof setTimeout> | null = null;
- let pmtilesRegistered = false;
- let pmtilesProtocol: Protocol | null = null;
- let mapResizeObserver: ResizeObserver | null = null;
- const titleText = computed(() => props.title || '通信基站');
- const isPointInsideChinaBounds = (lng: number, lat: number) => {
- if (lng === 0 && lat === 0) return false;
- return (
- lng >= CHINA_BOUNDS.minLng &&
- lng <= CHINA_BOUNDS.maxLng &&
- lat >= CHINA_BOUNDS.minLat &&
- lat <= CHINA_BOUNDS.maxLat
- );
- };
- const validPoints = computed<StationPoint[]>(() => {
- return (props.stations || [])
- .map((item, index) => {
- if (!item || typeof item !== 'object') return null;
- const longitudeText = String(item.personStationLongitude || '').trim();
- const latitudeText = String(item.personStationLatitude || '').trim();
- const lng = Number(longitudeText);
- const lat = Number(latitudeText);
- if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
- if (!isPointInsideChinaBounds(lng, lat)) return null;
- const regionId = String(item.personRegionId || '').trim();
- const cellId = String(item.personCellId || '').trim();
- const fallbackName = [regionId, cellId].filter(Boolean).join('-') || '基站位置';
- return {
- id: `${item.personStationName || ''}||${longitudeText}||${latitudeText}||${index}`,
- lng,
- lat,
- name: String(item.personStationName || '').trim() || fallbackName,
- regionId,
- cellId,
- longitudeText,
- latitudeText,
- };
- })
- .filter((item): item is StationPoint => !!item);
- });
- 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 normalized = 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: normalized };
- };
- const getFallbackStyle = () => ({
- version: 8,
- sources: {},
- layers: [{ id: 'portrait-station-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: 'portrait-offline-map-external',
- 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 },
- },
- {
- id: 'behavior-place-label',
- type: 'symbol',
- source: 'behavior-basemap',
- 'source-layer': 'place',
- minzoom: 3,
- filter: ['has', 'name'],
- layout: {
- 'text-field': ['coalesce', ['get', 'name:zh-Hans'], ['get', 'name:zh'], ['get', 'name']],
- 'text-font': ['Microsoft YaHei', 'Microsoft JhengHei', 'Noto Sans CJK SC'],
- 'text-size': 11,
- 'text-anchor': 'top',
- 'text-offset': [0, 0.6],
- },
- paint: {
- 'text-color': '#1f2937',
- 'text-halo-color': 'rgba(255, 255, 255, 0.98)',
- 'text-halo-width': 1.2,
- },
- },
- ],
- };
- };
- 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 custom = (window as PortraitStationWindow).__BEHAVIOR_SPACETIME_STYLE__ || STYLE_PATH;
- try {
- const offlineMapDir = getOfflineMapDir();
- const offlinePmtilesFilePath = getOfflineMapFilePath('china.pmtiles');
- const offlineStylePath = getOfflineMapStylePath();
- if (offlineMapDir && offlinePmtilesFilePath && offlineStylePath) {
- const styleText = await readOfflineMapStyle();
- if (styleText) {
- const style = JSON.parse(styleText);
- mapMode.value = 'template';
- errorMsg.value = '';
- return normalizePmtilesSources(style as Record<string, any>, offlineStylePath, offlinePmtilesFilePath);
- }
- }
- const externalStyle = buildExternalMapStyle();
- if (externalStyle) {
- mapMode.value = 'template';
- errorMsg.value = '';
- return externalStyle;
- }
- if (custom && typeof custom === 'object') {
- mapMode.value = 'template';
- errorMsg.value = '';
- return normalizePmtilesSources(custom, window.location.href, offlinePmtilesFilePath || undefined);
- }
- const styleUrl = resolveStyleAssetUrl(String(custom), 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();
- mapMode.value = 'template';
- errorMsg.value = '';
- return normalizePmtilesSources(
- style as Record<string, any>,
- response.url || styleUrl,
- offlinePmtilesFilePath || undefined,
- );
- } catch (error) {
- errorMsg.value = error instanceof Error ? error.message : 'style error';
- }
- mapMode.value = 'fallback';
- return getFallbackStyle();
- };
- const toGeoJson = () => ({
- type: 'FeatureCollection',
- features: validPoints.value.map((item) => ({
- type: 'Feature',
- geometry: {
- type: 'Point',
- coordinates: [item.lng, item.lat],
- },
- properties: {
- id: item.id,
- name: item.name,
- regionId: item.regionId || '-',
- cellId: item.cellId || '-',
- longitude: item.longitudeText,
- latitude: item.latitudeText,
- },
- })),
- });
- const createPopupContent = (properties: Record<string, unknown>) => {
- const container = document.createElement('div');
- container.className = 'portrait-station-map-popup';
- const title = document.createElement('div');
- title.className = 'portrait-station-map-popup__title';
- title.textContent = String(properties.name || '基站位置');
- container.appendChild(title);
- const entries = [
- ['LAC', String(properties.regionId || '-')],
- ['CID', String(properties.cellId || '-')],
- ['经度', String(properties.longitude || '-')],
- ['纬度', String(properties.latitude || '-')],
- ];
- entries.forEach(([label, value]) => {
- const row = document.createElement('div');
- row.className = 'portrait-station-map-popup__row';
- row.textContent = `${label}:${value}`;
- container.appendChild(row);
- });
- return container;
- };
- const getPointLabelTextSize = (labelCount: number) => {
- if (labelCount > POINT_LABEL_HEAVY_LIMIT) return 10;
- if (labelCount > POINT_LABEL_DENSE_LIMIT) return 10.5;
- if (labelCount > POINT_LABEL_MEDIUM_LIMIT) return 11;
- if (labelCount > POINT_LABEL_LIGHT_LIMIT) return 11.5;
- return 11.5;
- };
- 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 registerMapPinImage = async (instance: MapLibreMap) => {
- if (instance.hasImage(MAP_PIN_ICON_STATION)) {
- instance.removeImage(MAP_PIN_ICON_STATION);
- }
- const image = await createMapPinImage('#1677ff', 24);
- if (!image) return;
- instance.addImage(MAP_PIN_ICON_STATION, image, { pixelRatio: 2 });
- };
- const updateSource = () => {
- if (!map.value || !mapLoaded.value) return;
- (map.value.getSource(SOURCE_ID) as any)?.setData(toGeoJson() as any);
- };
- const clearMapLabelRestoreTimer = () => {
- if (mapLabelRestoreTimer !== null) {
- clearTimeout(mapLabelRestoreTimer);
- mapLabelRestoreTimer = null;
- }
- };
- const setMapLabelLayerVisibility = (visible: boolean) => {
- if (!map.value || !mapLoaded.value || !map.value.getLayer(POINT_LABEL_LAYER_ID)) return;
- map.value.setLayoutProperty(POINT_LABEL_LAYER_ID, 'visibility', visible ? 'visible' : 'none');
- };
- const syncMapLabelLayerStyle = () => {
- if (!map.value || !mapLoaded.value || !map.value.getLayer(POINT_LABEL_LAYER_ID)) return;
- map.value.setLayoutProperty(POINT_LABEL_LAYER_ID, 'text-size', getPointLabelTextSize(validPoints.value.length));
- };
- const syncMapLabelLayerVisibility = () => {
- setMapLabelLayerVisibility(validPoints.value.length > 0);
- };
- const scheduleMapLabelLayerRestore = () => {
- clearMapLabelRestoreTimer();
- mapLabelRestoreTimer = setTimeout(() => {
- mapLabelRestoreTimer = null;
- syncMapLabelLayerVisibility();
- }, 120);
- };
- 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: '280px',
- })
- .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 item = validPoints.value[0];
- map.value.easeTo({ center: [item.lng, item.lat], zoom: 13.8, duration: animate ? 500 : 0 });
- return;
- }
- const bounds = new maplibregl.LngLatBounds();
- validPoints.value.forEach((item) => bounds.extend([item.lng, item.lat]));
- map.value.fitBounds(bounds, { padding: 64, maxZoom: 13.8, duration: animate ? 700 : 0 });
- };
- const bindMapEvents = (instance: MapLibreMap) => {
- const handleClusterClick = (event: maplibregl.MapLayerMouseEvent) => {
- const clusterFeature = event.features?.[0] as any;
- const clusterId = clusterFeature?.properties?.cluster_id;
- const pointCount = Number(clusterFeature?.properties?.point_count || 0);
- const clusterCoordinates = clusterFeature?.geometry?.coordinates as [number, number] | undefined;
- if (clusterId === undefined || !clusterCoordinates) return;
- const source = instance.getSource(SOURCE_ID) as any;
- if (!source?.getClusterExpansionZoom || !source?.getClusterLeaves) return;
- void (async () => {
- try {
- const leaves = (await source.getClusterLeaves(clusterId, Math.max(pointCount, 1), 0)) as Array<any>;
- const coordinates = leaves
- .map((feature) => feature?.geometry?.coordinates)
- .filter(
- (coords): coords is [number, number] =>
- Array.isArray(coords) &&
- coords.length >= 2 &&
- typeof coords[0] === 'number' &&
- typeof coords[1] === 'number',
- );
- if (coordinates.length > 1) {
- const bounds = new maplibregl.LngLatBounds();
- coordinates.forEach((coords) => bounds.extend(coords));
- instance.fitBounds(bounds, {
- padding: 96,
- duration: 420,
- maxZoom: MAP_MAX_ZOOM,
- });
- return;
- }
- const zoom = await source.getClusterExpansionZoom(clusterId);
- instance.easeTo({
- center: coordinates[0] || clusterCoordinates,
- zoom: Math.min(Math.max(zoom, POINT_CLUSTER_MAX_ZOOM + 1.2), MAP_MAX_ZOOM),
- duration: 420,
- });
- } catch {
- const zoom = await source.getClusterExpansionZoom(clusterId);
- instance.easeTo({
- center: clusterCoordinates,
- zoom: Math.min(Math.max(zoom, POINT_CLUSTER_MAX_ZOOM + 1.2), MAP_MAX_ZOOM),
- duration: 420,
- });
- }
- })();
- };
- instance.on('movestart', () => {
- clearMapLabelRestoreTimer();
- setMapLabelLayerVisibility(false);
- });
- instance.on('moveend', () => {
- scheduleMapLabelLayerRestore();
- });
- instance.on('click', CLUSTER_LAYER_ID, handleClusterClick);
- instance.on('click', CLUSTER_COUNT_LAYER_ID, handleClusterClick);
- instance.on('click', POINT_LAYER_ID, (event) => {
- openPointPopup(instance, event.features?.[0]);
- });
- instance.on('click', POINT_LABEL_LAYER_ID, (event) => {
- openPointPopup(instance, event.features?.[0]);
- });
- instance.on('mouseenter', CLUSTER_LAYER_ID, () => {
- instance.getCanvas().style.cursor = 'pointer';
- });
- instance.on('mouseleave', CLUSTER_LAYER_ID, () => {
- instance.getCanvas().style.cursor = '';
- });
- instance.on('mouseenter', CLUSTER_COUNT_LAYER_ID, () => {
- instance.getCanvas().style.cursor = 'pointer';
- });
- instance.on('mouseleave', CLUSTER_COUNT_LAYER_ID, () => {
- instance.getCanvas().style.cursor = '';
- });
- instance.on('mouseenter', POINT_LAYER_ID, () => {
- instance.getCanvas().style.cursor = 'pointer';
- });
- instance.on('mouseleave', POINT_LAYER_ID, () => {
- instance.getCanvas().style.cursor = '';
- });
- instance.on('mouseenter', POINT_LABEL_LAYER_ID, () => {
- instance.getCanvas().style.cursor = 'pointer';
- });
- instance.on('mouseleave', POINT_LABEL_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: 4,
- maxZoom: MAP_MAX_ZOOM,
- attributionControl: false,
- crossSourceCollisions: false,
- fadeDuration: 0,
- renderWorldCopies: false,
- });
- map.value = instance;
- instance.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
- instance.on('load', async () => {
- mapLoaded.value = true;
- await registerMapPinImage(instance);
- instance.addSource(SOURCE_ID, {
- type: 'geojson',
- data: toGeoJson() as any,
- cluster: true,
- clusterRadius: POINT_CLUSTER_RADIUS,
- clusterMaxZoom: POINT_CLUSTER_MAX_ZOOM,
- });
- instance.addLayer({
- id: CLUSTER_LAYER_ID,
- type: 'circle',
- source: SOURCE_ID,
- filter: ['has', 'point_count'],
- paint: {
- 'circle-radius': ['step', ['get', 'point_count'], 17, 6, 20, 12, 23, 20, 26],
- 'circle-color': '#2563eb',
- 'circle-stroke-color': '#ffffff',
- 'circle-stroke-width': 2.5,
- 'circle-opacity': 0.96,
- },
- });
- instance.addLayer({
- id: CLUSTER_COUNT_LAYER_ID,
- type: 'symbol',
- source: SOURCE_ID,
- filter: ['has', 'point_count'],
- layout: {
- 'text-field': ['to-string', ['get', 'point_count']],
- 'text-size': 11,
- 'text-font': ['Microsoft YaHei', 'Microsoft JhengHei', 'Noto Sans CJK SC'],
- 'text-allow-overlap': true,
- 'text-ignore-placement': true,
- },
- paint: {
- 'text-color': '#ffffff',
- },
- });
- instance.addLayer({
- id: POINT_LAYER_ID,
- type: 'symbol',
- source: SOURCE_ID,
- filter: ['!', ['has', 'point_count']],
- layout: {
- 'icon-image': MAP_PIN_ICON_STATION,
- 'icon-size': ['interpolate', ['linear'], ['zoom'], 4, 1.52, 10.6, 1.76, 14, 1.92],
- 'icon-anchor': 'bottom',
- 'icon-allow-overlap': true,
- 'icon-ignore-placement': true,
- },
- });
- instance.addLayer({
- id: POINT_LABEL_LAYER_ID,
- type: 'symbol',
- source: SOURCE_ID,
- filter: ['!', ['has', 'point_count']],
- layout: {
- 'text-field': ['coalesce', ['get', 'name'], ''],
- 'text-font': ['Microsoft YaHei', 'Microsoft JhengHei', 'Noto Sans CJK SC'],
- 'text-size': getPointLabelTextSize(validPoints.value.length),
- 'text-offset': [0, 0.58],
- 'text-anchor': 'top',
- 'text-justify': 'center',
- 'text-line-height': 1.22,
- 'text-max-width': 18,
- 'text-allow-overlap': true,
- 'text-ignore-placement': true,
- 'text-optional': true,
- 'text-padding': 2,
- },
- paint: {
- 'text-color': '#0f172a',
- 'text-halo-color': 'rgba(255, 255, 255, 0.96)',
- 'text-halo-width': 1.5,
- },
- });
- bindMapEvents(instance);
- fitMap(false);
- syncMapLabelLayerVisibility();
- setTimeout(() => instance.resize(), 0);
- });
- instance.on('error', (event) => {
- errorMsg.value = event.error?.message || 'map error';
- mapMode.value = 'fallback';
- });
- };
- const syncMap = async () => {
- if (!props.open) return;
- await nextTick();
- await initMap();
- if (!map.value) return;
- map.value.resize();
- updateSource();
- syncMapLabelLayerStyle();
- syncMapLabelLayerVisibility();
- fitMap(false);
- };
- const observeMapContainer = () => {
- if (typeof ResizeObserver === 'undefined' || mapResizeObserver || !mapBoxRef.value) return;
- mapResizeObserver = new ResizeObserver(() => {
- if (!props.open || !map.value) return;
- map.value.resize();
- });
- mapResizeObserver.observe(mapBoxRef.value);
- };
- const unobserveMapContainer = () => {
- mapResizeObserver?.disconnect();
- mapResizeObserver = null;
- };
- const recreateMap = async () => {
- mapLoaded.value = false;
- errorMsg.value = '';
- clearMapLabelRestoreTimer();
- popup?.remove();
- popup = null;
- map.value?.remove();
- map.value = null;
- await nextTick();
- if (props.open) {
- await syncMap();
- }
- };
- const handleOfflineMapDirChanged = () => {
- void recreateMap();
- };
- watch(
- () => props.open,
- (open) => {
- if (!open) {
- unobserveMapContainer();
- popup?.remove();
- popup = null;
- return;
- }
- observeMapContainer();
- void syncMap();
- },
- );
- watch(
- validPoints,
- () => {
- if (!props.open) return;
- updateSource();
- syncMapLabelLayerStyle();
- syncMapLabelLayerVisibility();
- fitMap(false);
- },
- { deep: true },
- );
- onMounted(() => {
- window.addEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, handleOfflineMapDirChanged);
- observeMapContainer();
- });
- onBeforeUnmount(() => {
- window.removeEventListener(OFFLINE_MAP_DIR_CHANGED_EVENT, handleOfflineMapDirChanged);
- unobserveMapContainer();
- clearMapLabelRestoreTimer();
- popup?.remove();
- popup = null;
- map.value?.remove();
- map.value = null;
- mapLoaded.value = false;
- });
- </script>
- <style lang="less">
- @prefix-cls: ~'jeesite-portrait-station-map-modal';
- .portrait-station-map-modal-wrap {
- .ant-modal-content {
- display: flex;
- flex-direction: column;
- max-height: calc(100vh - 32px);
- }
- .ant-modal-body {
- flex: 1 1 auto;
- min-height: 0;
- padding: 20px;
- }
- }
- .@{prefix-cls}.station-map-modal {
- display: flex;
- flex-direction: column;
- height: calc(100vh - 160px);
- min-height: 620px;
- .station-map-modal__map-box {
- flex: 1 1 auto;
- min-height: 0;
- position: relative;
- overflow: hidden;
- background: #edf3f8;
- border: 1px solid #e5e7eb;
- border-radius: 14px;
- }
- .station-map-modal__map {
- width: 100%;
- height: 100%;
- }
- .station-map-modal__empty {
- position: absolute;
- inset: 0;
- z-index: 2;
- display: flex;
- align-items: center;
- justify-content: center;
- pointer-events: none;
- background: rgb(248 250 252 / 68%);
- font-size: 14px;
- color: #64748b;
- }
- .station-map-modal__error {
- position: absolute;
- top: 14px;
- left: 14px;
- z-index: 3;
- max-width: calc(100% - 120px);
- padding: 6px 10px;
- font-size: 12px;
- line-height: 18px;
- color: #b45309;
- 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%);
- }
- }
- .portrait-station-map-popup {
- min-width: 180px;
- padding: 2px 2px 0;
- font-size: 12px;
- line-height: 1.7;
- color: #475569;
- }
- .portrait-station-map-popup__title {
- margin-bottom: 4px;
- font-size: 13px;
- font-weight: 600;
- line-height: 20px;
- color: #0f172a;
- }
- .portrait-station-map-popup__row + .portrait-station-map-popup__row {
- margin-top: 2px;
- }
- </style>
|