天地图应用篇: 增加缩放、比例尺控件
案例截图:
- 效果图示下:
案例代码:
代码示下:
<template> <div class="tianditu-map-container"> <!-- 顶部搜索和天气栏 --> <div class="map-top-bar"> <div class="search-weather-box search-box-official"> <el-autocomplete v-if="mapOptions.showSearch" v-model="searchKeyword" :fetch-suggestions="fetchSearchSuggestions" placeholder="搜索地名、公交站、地铁站" class="search-input" @select="handleSelectSuggestion" clearable > <template #default="{ item, query, highlighted }"> <div class="search-suggestion-item" :class="{ 'is-active': highlighted }"> <span class="search-suggestion-icon">{{ getPoiIcon(item.catalog) }}</span> <span class="search-suggestion-title" v-html="highlightKeyword(item.value, query)"></span> <span class="search-suggestion-type">{{ item.catalog }}</span> <span class="search-suggestion-meta">{{ item.address }}</span> </div> </template> <template #append> <el-button :icon="Search" @click="searchLocation" :loading="searchLoading"> <!-- 搜索 --> </el-button> </template> </el-autocomplete> </div> </div> <!-- 右上角按钮组 --> <div class="map-toolbar-top-right"> <el-tooltip content="全屏" placement="left"> <el-button v-if="mapOptions.showFullscreen" :icon="isFullscreen ? Fold : FullScreen" @click="toggleFullscreen" circle /> </el-tooltip> </div> <!-- 地图容器 --> <div id="tianditu-map" class="map-container"></div> <!-- 标记管理和表单 --> <div class="marker-form" v-if="showMarkerForm"> <el-form :model="markerForm" label-width="100px"> <el-form-item label="名称" prop="name"> <el-input v-model="markerForm.name" placeholder="请输入标记名称" /> </el-form-item> <el-form-item label="纬度" prop="lat"> <el-input v-model="markerForm.lat" type="number" placeholder="请输入纬度" /> </el-form-item> <el-form-item label="经度" prop="lng"> <el-input v-model="markerForm.lng" type="number" placeholder="请输入经度" /> </el-form-item> <el-form-item label="描述" prop="description"> <el-input v-model="markerForm.description" type="textarea" :rows="2" placeholder="请输入描述" /> </el-form-item> <el-form-item> <el-button type="primary" @click="saveMarker">保存</el-button> <el-button @click="cancelMarker">取消</el-button> </el-form-item> </el-form> </div> <div class="markers-list" v-if="markers.length > 0"> <h4>已保存的标记</h4> <el-table :data="markers" style="width: 100%"> <el-table-column prop="name" label="名称" /> <el-table-column prop="description" label="描述" /> <el-table-column label="坐标" width="200"> <template #default="scope"> {{ scope.row.lat.toFixed(6) }}, {{ scope.row.lng.toFixed(6) }} </template> </el-table-column> <el-table-column label="操作" width="250"> <template #default="scope"> <el-button size="small" @click="centerOnMarker(scope.row)" :icon="Location">定位</el-button> <el-button size="small" type="primary" @click="navigateToMarker(scope.row)" :icon="Position">导航</el-button> <el-button size="small" type="danger" @click="removeMarker(scope.$index)" :icon="Delete">删除</el-button> </template> </el-table-column> </el-table> </div> </div> </template> <script setup> import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { Location, Position, Search, FullScreen, Fold, Promotion, Guide, Place, School, Delete } from '@element-plus/icons-vue' import { h } from 'vue' // 天地图API密钥 const TIANDITU_KEY = '08pfngl6ytjjs8sjgjeef7ac2lsiissc' // 响应式数据 const markers = ref([]) const showMarkerForm = ref(false) const markerForm = ref({ name: '', lat: '', lng: '', description: '' }) // 地图相关变量 let markerObjs = [] let mapObj = null let mapTypeControl = null let customScaleControl = null // 功能开关参数 const mapOptions = reactive({ showZoom: true, // 是否显示缩放控件 showSearch: true, // 是否显示搜索框 showLayerSwitch: true, // 是否显示图层切换控件 showFullscreen: true, // 是否显示全屏切换控件 // 比例尺配置控件 showScale: true, // 是否显示比例尺控件 scaleUnit: 'Km', // 默认值km,比例尺单位配置:'Km'(公里), 'mi'(英里), 'both'(双单位) scaleUnitDisplay: 'chinese' // 单位显示方式:'word'(英文单词), 'chinese'(汉字), 'both'(双语言) }) // 其他状态 const isFullscreen = ref(false) const searchKeyword = ref('') const searchLoading = ref(false) // 创建自定义比例尺 function createCustomScale() { if (!mapObj) return const scaleContainer = document.createElement('div') scaleContainer.className = 'custom-scale-control' scaleContainer.style.cssText = ` position: absolute; bottom: 10px; left: 10px; background: rgba(255, 255, 255, 0.9); border: 1px solid #ccc; border-radius: 2px; padding: 2px 4px; font-size: 11px; font-family: Arial, sans-serif; z-index: 1000; min-width: 60px; text-align: center; color: #333; ` // 单位显示映射函数 function getUnitDisplay(unit, value) { const unitMappings = { 'Km': { 'word': 'Km', 'chinese': '公里', 'both': 'Km/公里' }, 'm': { 'word': 'm', 'chinese': '米', 'both': 'm/米' }, 'mi': { 'word': 'mi', 'chinese': '英里', 'both': 'mi/英里' }, 'ft': { 'word': 'ft', 'chinese': '英尺', 'both': 'ft/英尺' } } const displayMode = mapOptions.scaleUnitDisplay const unitMap = unitMappings[unit] || { 'word': unit, 'chinese': unit, 'both': unit } return unitMap[displayMode] || unit } function updateScale() { const zoom = mapObj.getZoom() const center = mapObj.getCenter() const lat = center.getLat() // 计算比例尺 const earthCircumference = 40075016.686 const pixelsPerTile = 256 const tilesPerZoom = Math.pow(2, zoom) const pixelsPerDegree = (pixelsPerTile * tilesPerZoom) / 360 const metersPerDegree = earthCircumference * Math.cos(lat * Math.PI / 180) / 360 const metersPerPixel = metersPerDegree / pixelsPerDegree // 计算合适的比例尺距离和宽度 let targetDistance, scaleWidth, unit, displayText if (mapOptions.scaleUnit === 'Km' || mapOptions.scaleUnit === 'both') { // 公里模式 const mPerPixel = metersPerPixel const targetWidth = 100 // 目标宽度(像素) let meters = mPerPixel * targetWidth // 选择合适的比例尺距离 if (meters >= 1000) { // 超过1km,使用原来的逻辑 const Km = meters / 1000 if (Km >= 1000) { targetDistance = Math.floor(Km / 1000) * 1000 unit = 'Km' } else if (Km >= 100) { targetDistance = Math.floor(Km / 100) * 100 unit = 'Km' } else if (Km >= 10) { targetDistance = Math.floor(Km / 10) * 10 unit = 'Km' } else if (Km >= 1) { targetDistance = Math.floor(Km) unit = 'Km' } else { targetDistance = Math.floor(Km * 1000) unit = 'm' } scaleWidth = targetDistance / (unit === 'Km' ? mPerPixel / 1000 : mPerPixel) } else { // 1km范围内,使用新的逻辑(500m, 300m, 200m, 100m, 50m, 30m, 20m, 10m, 1m) if (meters >= 500) { targetDistance = 500 unit = 'm' } else if (meters >= 300) { targetDistance = 300 unit = 'm' } else if (meters >= 200) { targetDistance = 200 unit = 'm' } else if (meters >= 100) { targetDistance = 100 unit = 'm' } else if (meters >= 50) { targetDistance = 50 unit = 'm' } else if (meters >= 30) { targetDistance = 30 unit = 'm' } else if (meters >= 20) { targetDistance = 20 unit = 'm' } else if (meters >= 10) { targetDistance = 10 unit = 'm' } else { targetDistance = 1 unit = 'm' } scaleWidth = targetDistance / mPerPixel } const unitDisplay = getUnitDisplay(unit, targetDistance) displayText = `${targetDistance} ${unitDisplay}` } else if (mapOptions.scaleUnit === 'mi') { // 英里模式 const mPerPixel = metersPerPixel const targetWidth = 100 let meters = mPerPixel * targetWidth // 选择合适的比例尺距离 if (meters >= 1000) { // 超过1km,使用原来的逻辑 const miPerPixel = mPerPixel * 0.000621371 const Km = meters / 1000 let mi = Km * 0.621371 if (mi >= 100) { targetDistance = Math.floor(mi / 100) * 100 unit = 'mi' } else if (mi >= 10) { targetDistance = Math.floor(mi / 10) * 10 unit = 'mi' } else if (mi >= 1) { targetDistance = Math.floor(mi) unit = 'mi' } else { targetDistance = Math.floor(mi * 5280) unit = 'ft' } scaleWidth = targetDistance / (unit === 'ft' ? miPerPixel * 5280 : miPerPixel) } else { // 1km范围内,使用新的逻辑 let targetMeters if (meters >= 500) { targetMeters = 500 } else if (meters >= 300) { targetMeters = 300 } else if (meters >= 200) { targetMeters = 200 } else if (meters >= 100) { targetMeters = 100 } else if (meters >= 50) { targetMeters = 50 } else if (meters >= 30) { targetMeters = 30 } else if (meters >= 20) { targetMeters = 20 } else if (meters >= 10) { targetMeters = 10 } else { targetMeters = 1 } // 转换为英里 const miles = targetMeters * 0.000621371 if (miles >= 1) { targetDistance = Math.floor(miles) unit = 'mi' } else { targetDistance = Math.floor(miles * 5280) unit = 'ft' } scaleWidth = targetMeters / mPerPixel } const unitDisplay = getUnitDisplay(unit, targetDistance) displayText = `${targetDistance} ${unitDisplay}` } if (mapOptions.scaleUnit === 'both') { let miText if (unit === 'Km') { const miles = targetDistance * 0.621371 if (miles >= 1) { const miUnitDisplay = getUnitDisplay('mi', Math.floor(miles)) miText = `${Math.floor(miles)} ${miUnitDisplay}` } else { const ftUnitDisplay = getUnitDisplay('ft', Math.floor(miles * 5280)) miText = `${Math.floor(miles * 5280)} ${ftUnitDisplay}` } } else if (unit === 'm') { const miles = targetDistance * 0.000621371 if (miles >= 1) { const miUnitDisplay = getUnitDisplay('mi', Math.floor(miles)) miText = `${Math.floor(miles)} ${miUnitDisplay}` } else { const ftUnitDisplay = getUnitDisplay('ft', Math.floor(miles * 5280)) miText = `${Math.floor(miles * 5280)} ${ftUnitDisplay}` } } else if (unit === 'mi') { const meters = targetDistance * 1609.34 const mUnitDisplay = getUnitDisplay('m', Math.floor(meters)) miText = `${Math.floor(meters)} ${mUnitDisplay}` } else { const meters = targetDistance * 5280 * 0.3048 const mUnitDisplay = getUnitDisplay('m', Math.floor(meters)) miText = `${Math.floor(meters)} ${mUnitDisplay}` } displayText = `${displayText} / ${miText}` } // 限制比例尺宽度在合理范围内,确保线条长度适中 scaleWidth = Math.max(40, Math.min(120, scaleWidth)) scaleContainer.innerHTML = ` <div style="margin-bottom: 2px;">${displayText}</div> <div style="height: 2px; background: #333; width: ${scaleWidth}px; margin: 0 auto;"></div> ` } updateScale() mapObj.addEventListener('zoomend', updateScale) mapObj.addEventListener('moveend', updateScale) // 将比例尺添加到地图容器 const mapContainer = mapObj.getContainer() mapContainer.appendChild(scaleContainer) // 保存引用以便清理 customScaleControl = scaleContainer } /** * 比例尺配置说明: * * 通过修改 mapOptions.scaleUnit 来控制比例尺显示单位: * - 'Km': 显示公里/米(1km范围内使用米,超过1km使用公里) * - 'mi': 显示英里/英尺(1km范围内使用英里/英尺,超过1km使用英里) * - 'both': 同时显示公里/米和英里/英尺 * * 通过修改 mapOptions.scaleUnitDisplay 来控制单位显示方式: * - 'word': 显示英文单词(km, m, mi, ft) * - 'chinese': 显示汉字(公里, 米, 英里, 英尺) * - 'both': 显示双语言(km/公里, m/米, mi/英里, ft/英尺) * * 比例尺长度单位: * - 1km范围内:500m, 300m, 200m, 100m, 50m, 30m, 20m, 10m, 1m * - 超过1km:按原来逻辑显示(1000km, 100km, 10km, 1km等) * * 示例: * mapOptions.scaleUnit = 'mi' // 切换到英里显示 * mapOptions.scaleUnit = 'both' // 显示双单位 * mapOptions.scaleUnitDisplay = 'chinese' // 切换到汉字显示 * mapOptions.scaleUnitDisplay = 'both' // 显示双语言 * * 比例尺会自动显示在地图左下角,无需额外操作 */ // 全屏切换 function toggleFullscreen() { const el = document.getElementById('tianditu-map')?.parentElement if (!el) return if (!isFullscreen.value) { if (el.requestFullscreen) el.requestFullscreen() else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen() else if (el.mozRequestFullScreen) el.mozRequestFullScreen() else if (el.msRequestFullscreen) el.msRequestFullscreen() isFullscreen.value = true } else { if (document.exitFullscreen) document.exitFullscreen() else if (document.webkitExitFullscreen) document.webkitExitFullscreen() else if (document.mozCancelFullScreen) document.mozCancelFullScreen() else if (document.msExitFullscreen) document.msExitFullscreen() isFullscreen.value = false } } // 监听全屏状态变化 document.addEventListener('fullscreenchange', () => { isFullscreen.value = !!document.fullscreenElement }) // 搜索相关函数 function highlightKeyword(text, keyword) { if (!keyword) return text const reg = new RegExp(`(${keyword})`, 'gi') return text.replace(reg, '<span class="search-highlight">$1</span>') } function getPoiIcon(catalog) { if (!catalog) return h(Location) if (catalog.includes('地铁')) return h(Promotion) if (catalog.includes('公交')) return h(Guide) if (catalog.includes('火车站') || catalog.includes('高铁')) return h(Place) if (catalog.includes('学校')) return h(School) return h(Location) } async function fetchSearchSuggestions(query, cb) { if (!query) { cb([]); return } const url = `https://api.tianditu.gov.cn/search?postStr={\"keyWord\":\"${encodeURIComponent(query)}\",\"level\":\"9\",\"queryType\":\"1\",\"start\":0,\"count\":10}&type=query&tk=${TIANDITU_KEY}` try { const res = await fetch(url) const data = await res.json() if (data && data.pois && Array.isArray(data.pois)) { cb(data.pois.map(item => ({ value: item.name, address: item.address, catalog: item.catalog, lon: item.lonlat.split(' ')[0], lat: item.lonlat.split(' ')[1] }))) } else { cb([]) } } catch { cb([]) } } function handleSelectSuggestion(item) { if (item.lon && item.lat && mapObj) { mapObj.centerAndZoom(new window.T.LngLat(item.lon, item.lat), 16) // 添加高亮marker const marker = new window.T.Marker(new window.T.LngLat(item.lon, item.lat)) mapObj.addOverLay(marker) // 弹窗显示详细信息 const popupContent = ` <div style='min-width:180px;'> <h4 style='margin:0 0 8px 0;'>${item.value}</h4> <div style='font-size:13px;color:#666;margin-bottom:6px;'>${item.catalog || ''}</div> <div style='font-size:12px;color:#999;margin-bottom:8px;'>${item.address || ''}</div> <div style='font-size:12px;color:#999;'>坐标: ${item.lat}, ${item.lon}</div> </div> ` const infoWin = new window.T.InfoWindow(popupContent, { offset: new window.T.Pixel(0, -20) }) mapObj.openInfoWindow(infoWin, new window.T.LngLat(item.lon, item.lat)) ElMessage.success('已定位到:' + item.value) } } // 动态加载天地图API function loadTiandituScript() { return new Promise((resolve, reject) => { if (window.T) { resolve() return } const script = document.createElement('script') script.src = `https://api.tianditu.gov.cn/api?v=4.0&tk=${TIANDITU_KEY}` script.onload = resolve script.onerror = reject document.head.appendChild(script) }) } // 初始化地图 async function initMap() { await loadTiandituScript() await nextTick() if (!window.T) { ElMessage.error('天地图API加载失败') return } mapObj = new window.T.Map('tianditu-map') mapObj.centerAndZoom(new window.T.LngLat(116.4074, 39.9042), 12) mapObj.enableScrollWheelZoom() mapObj.enableDoubleClickZoom() mapObj.enableKeyboard() // 添加控件 if (mapOptions.showZoom) { const zoomCtrl = new window.T.Control.Zoom({ position: window.T_ANCHOR_BOTTOM_RIGHT }) mapObj.addControl(zoomCtrl) } // 添加控件 (图层切换) if (mapOptions.showLayerSwitch) { mapTypeControl = new window.T.Control.MapType() mapObj.addControl(mapTypeControl) } // 添加自定义比例尺 if (mapOptions.showScale) { createCustomScale() } // 地图点击事件 mapObj.addEventListener('click', onMapClick) } // 地图点击事件 function onMapClick(e) { markerForm.value.lat = e.latlng.getLat().toFixed(6) markerForm.value.lng = e.latlng.getLng().toFixed(6) showMarkerForm.value = true } // 标记相关函数 function saveMarker() { if (!markerForm.value.name || !markerForm.value.lat || !markerForm.value.lng) { ElMessage.warning('请填写完整信息') return } const markerData = { id: Date.now(), name: markerForm.value.name, lat: parseFloat(markerForm.value.lat), lng: parseFloat(markerForm.value.lng), description: markerForm.value.description } markers.value.push(markerData) addMarkerToMap(markerData) showMarkerForm.value = false ElMessage.success('标记保存成功') } function addMarkerToMap(markerData) { if (!window.T) return const marker = new window.T.Marker(new window.T.LngLat(markerData.lng, markerData.lat)) marker.data = markerData marker.addEventListener('click', (e) => { showMarkerPopup(e, markerData) }) mapObj.addOverLay(marker) markerObjs.push(marker) markerData._marker = marker } function showMarkerPopup(e, markerData) { const popupContent = ` <div style='min-width:180px;'> <h4 style='margin:0 0 8px 0;'>${markerData.name}</h4> <div style='font-size:13px;color:#666;margin-bottom:6px;'>${markerData.description || ''}</div> <div style='font-size:12px;color:#999;margin-bottom:8px;'>坐标: ${markerData.lat.toFixed(6)}, ${markerData.lng.toFixed(6)}</div> <div style='display:flex;gap:8px;'> <button onclick="window.tdtNavigate(${markerData.lat},${markerData.lng},'${markerData.name}')" style='background:#409EFF;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;'>导航</button> <button onclick="navigator.clipboard.writeText('${markerData.lat},${markerData.lng}')" style='background:#67C23A;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;'>复制坐标</button> </div> </div> ` const infoWin = new window.T.InfoWindow(popupContent, { offset: new window.T.Pixel(0, -20) }) mapObj.openInfoWindow(infoWin, new window.T.LngLat(markerData.lng, markerData.lat)) } function cancelMarker() { showMarkerForm.value = false markerForm.value = { name: '', lat: '', lng: '', description: '' } } function centerOnMarker(marker) { if (mapObj) { mapObj.centerAndZoom(new window.T.LngLat(marker.lng, marker.lat), 16) ElMessage.success('已定位到标记') } } function navigateToMarker(marker) { window.tdtNavigate(marker.lat, marker.lng, marker.name) } async function removeMarker(index) { const marker = markers.value[index] try { await ElMessageBox.confirm('确定要删除这个标记吗?', '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }) if (marker._marker) { mapObj.removeOverLay(marker._marker) const i = markerObjs.indexOf(marker._marker) if (i !== -1) markerObjs.splice(i, 1) } markers.value.splice(index, 1) ElMessage.success('标记已删除') } catch {} } async function searchLocation() { if (!searchKeyword.value.trim()) { ElMessage.warning('请输入地名') return } searchLoading.value = true const url = `https://api.tianditu.gov.cn/geocoder?ds={"keyWord":"${encodeURIComponent(searchKeyword.value)}"}&tk=${TIANDITU_KEY}` try { const res = await fetch(url) const data = await res.json() if (data && data.location) { const { lon, lat } = data.location mapObj.centerAndZoom(new window.T.LngLat(lon, lat), 16) ElMessage.success('已定位到:' + searchKeyword.value) } else { ElMessage.warning('未找到地名') } } catch { ElMessage.error('地名搜索失败') } searchLoading.value = false } // 导航跳转函数(全局) window.tdtNavigate = function(lat, lng, name) { const url = `https://map.tianditu.gov.cn/navigation.html?lat=${lat}&lng=${lng}&name=${encodeURIComponent(name)}` window.open(url, '_blank') } // 生命周期 onMounted(async () => { await initMap() }) onUnmounted(() => { if (mapObj) { markerObjs.forEach(marker => mapObj.removeOverLay(marker)) markerObjs.length = 0 if (customScaleControl) { customScaleControl.remove() customScaleControl = null } mapObj = null } if (window.tdtNavigate) delete window.tdtNavigate }) </script> <style scoped> .tianditu-map-container { position: relative; padding: 0; max-width: 100vw; height: 50vh; background: #f5f7fa; } .map-top-bar { position: absolute; top: 10px; left: 10px; z-index: 500; display: flex; flex-direction: row; align-items: center; } .search-weather-box { display: flex; align-items: center; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 8px 16px; gap: 16px; } .search-box-official { background: #fff; border: 1px solid #dcdfe6; box-shadow: 0 2px 8px rgba(0,0,0,0.08); border-radius: 8px; padding: 0; } .search-input { width: 340px; font-size: 15px; border-radius: 8px; border: none; box-shadow: none; } .map-toolbar-top-right { position: absolute; top: 16px; right: 120px; z-index: 500; display: flex; flex-direction: row; gap: 8px; } .map-container { width: 100%; height: 100%; border-radius: 0; overflow: hidden; border: none; margin: 0; } .marker-form { position: absolute; left: 50px; top: 80px; z-index: 30; background: var(--el-bg-color-page); padding: 20px; border-radius: 8px; border: 1px solid var(--el-border-color-light); } .markers-list { position: absolute; right: 32px; bottom: 32px; z-index: 30; background: var(--el-bg-color-page); padding: 20px; border-radius: 8px; border: 1px solid var(--el-border-color-light); max-width: 400px; max-height: 300px; overflow: auto; } .markers-list h4 { margin-bottom: 15px; color: var(--el-color-primary); } .search-suggestion-item { display: flex; align-items: center; padding: 6px 12px; border-radius: 6px; transition: background 0.2s; cursor: pointer; } .search-suggestion-item.is-active, .search-suggestion-item:hover { background: #f0f7ff; } .search-suggestion-icon { margin-right: 8px; color: #409EFF; font-size: 18px; display: flex; align-items: center; } .search-suggestion-title { font-weight: 500; color: #222; margin-right: 8px; } .search-suggestion-type { color: #aaa; font-size: 13px; margin-left: 8px; margin-right: 8px; } .search-suggestion-meta { color: #999; font-size: 13px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .search-highlight { color: #409EFF; background: #e6f7ff; border-radius: 2px; padding: 0 2px; } </style>
完结。