<template>
|
<div class="map-downloader">
|
<div class="toolbar">
|
<select v-model="previewPreset" class="source-select" @change="loadPreview">
|
<option
|
v-for="option in previewPresetOptions"
|
:key="option.value"
|
:value="option.value"
|
>{{ option.label }}</option>
|
</select>
|
<input
|
v-if="previewPreset === 'custom'"
|
v-model.trim="previewUrl"
|
class="source-input"
|
placeholder="请输入包含 {x}、{y}、{z} 的瓦片地址"
|
@change="savePreviewUrl"
|
/>
|
<button @click="loadPreview">加载地图</button>
|
<button @click="startDrawing">划范围</button>
|
<button v-if="rect" @click="isShow = true">下载</button>
|
</div>
|
|
<div v-if="rect" class="range-tools">
|
<button @click="clearRect">清空范围</button>
|
<button
|
:class="{ 'is-editing': isEditing }"
|
:disabled="isEditing"
|
@click="startEditing"
|
>{{ isEditing ? '正在编辑' : '开始编辑范围' }}</button>
|
<button :disabled="!isEditing" @click="stopEditing">结束编辑范围</button>
|
</div>
|
|
<div ref="cesiumContainer" class="cesium-map" @contextmenu.prevent></div>
|
<div ref="overviewContainer" class="overview-map"></div>
|
|
<div class="status-bar">
|
<span>{{ operationStatus }}</span>
|
<span>缩放级别:{{ zoom }}</span>
|
<span>中心经纬度:{{ lng }}, {{ lat }}</span>
|
<span v-if="rect">选中范围:{{ rectLngLat }}</span>
|
</div>
|
|
<el-dialog v-model="isShow" title="下载地图瓦片" width="60%" append-to-body>
|
<el-form label-width="100px">
|
<el-form-item label="选中范围">
|
<el-input :value="rectLngLat" readonly />
|
</el-form-item>
|
<el-form-item label="中心点">
|
<el-input :value="centerLnglat" readonly />
|
</el-form-item>
|
<el-form-item label="下载地图类型">
|
<el-select v-model="downloadPreset" style="width: 100%" @change="changeDownloadPreset">
|
<el-option
|
v-for="option in downloadPresetOptions"
|
:key="option.value"
|
:label="option.label"
|
:value="option.value"
|
/>
|
</el-select>
|
</el-form-item>
|
<el-form-item v-if="downloadPreset === 'custom'" label="下载源地址">
|
<el-input
|
v-model.trim="downloadUrl"
|
placeholder="请输入包含 {x}、{y}、{z} 的瓦片地址"
|
@change="saveDownloadUrl"
|
/>
|
</el-form-item>
|
<el-form-item label="路径规则">
|
<el-input :value="rule" readonly />
|
</el-form-item>
|
</el-form>
|
<el-table v-if="rect" :data="tableData" height="400">
|
<el-table-column prop="level" label="缩放级别" />
|
<el-table-column prop="num" label="瓦片数量" />
|
<el-table-column label="选中">
|
<template #default="scope">
|
<input v-model="zoomMap[scope.row.level]" type="checkbox" />
|
</template>
|
</el-table-column>
|
</el-table>
|
<template #footer>
|
<el-button type="primary" @click="download">下载</el-button>
|
</template>
|
</el-dialog>
|
|
<div v-show="isLoading" class="loading-mask">
|
<span>下载进度:{{ process }}%</span>
|
</div>
|
</div>
|
</template>
|
|
<script setup>
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import JSZip from 'jszip'
|
import {
|
CallbackProperty,
|
Cartesian3,
|
Cartographic,
|
Color,
|
ColorMaterialProperty,
|
GeographicTilingScheme,
|
Math as CesiumMath,
|
PolygonHierarchy,
|
Rectangle,
|
SceneMode,
|
ScreenSpaceEventHandler,
|
ScreenSpaceEventType,
|
UrlTemplateImageryProvider,
|
WebMapTileServiceImageryProvider,
|
Viewer
|
} from 'cesium'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
const PREVIEW_URL_STORAGE_KEY = 'online-map-download.cesium-preview-url-img-w'
|
const PREVIEW_PRESET_STORAGE_KEY = 'online-map-download.cesium-preview-preset'
|
const DOWNLOAD_URL_STORAGE_KEY = 'online-map-download.cesium-download-url-img-w'
|
const DOWNLOAD_PRESET_STORAGE_KEY = 'online-map-download.cesium-download-preset'
|
const TDT_TOKEN = 'e110584a27d506da2740edca951683f4'
|
const createTdtDataServerUrl = (type) => `https://t{s}.tianditu.gov.cn/DataServer?T=${type}&x={x}&y={y}&l={z}&tk=${TDT_TOKEN}`
|
const DEFAULT_TILE_URL = createTdtDataServerUrl('img_w')
|
const PREVIEW_PRESETS = {
|
'imagery-geographic': [createTdtDataServerUrl('img_c'), createTdtDataServerUrl('cia_c')],
|
'imagery-mercator': [createTdtDataServerUrl('img_w'), createTdtDataServerUrl('cia_w')],
|
'vector-geographic': [createTdtDataServerUrl('vec_c'), createTdtDataServerUrl('cva_c')],
|
'vector-mercator': [createTdtDataServerUrl('vec_w'), createTdtDataServerUrl('cva_w')]
|
}
|
const previewPresetOptions = [
|
{ value: 'imagery-geographic', label: '天地图影像(经纬度切片,含注记)' },
|
{ value: 'imagery-mercator', label: '天地图影像(Web 墨卡托切片,含注记)' },
|
{ value: 'vector-geographic', label: '天地图矢量(经纬度切片,含注记)' },
|
{ value: 'vector-mercator', label: '天地图矢量(Web 墨卡托切片,含注记)' },
|
{ value: 'custom', label: '自定义瓦片地址' }
|
]
|
const DOWNLOAD_PRESETS = {
|
'imagery-geographic': createTdtDataServerUrl('img_c'),
|
'imagery-mercator': createTdtDataServerUrl('img_w'),
|
'vector-geographic': createTdtDataServerUrl('vec_c'),
|
'vector-mercator': createTdtDataServerUrl('vec_w'),
|
'imagery-label-geographic': createTdtDataServerUrl('cia_c'),
|
'imagery-label-mercator': createTdtDataServerUrl('cia_w'),
|
'vector-label-geographic': createTdtDataServerUrl('cva_c'),
|
'vector-label-mercator': createTdtDataServerUrl('cva_w')
|
}
|
const downloadPresetOptions = [
|
{ value: 'imagery-geographic', label: '天地图影像(经纬度切片)' },
|
{ value: 'imagery-mercator', label: '天地图影像(Web 墨卡托切片)' },
|
{ value: 'vector-geographic', label: '天地图矢量(经纬度切片)' },
|
{ value: 'vector-mercator', label: '天地图矢量(Web 墨卡托切片)' },
|
{ value: 'imagery-label-geographic', label: '天地图影像注记(经纬度切片)' },
|
{ value: 'imagery-label-mercator', label: '天地图影像注记(Web 墨卡托切片)' },
|
{ value: 'vector-label-geographic', label: '天地图矢量注记(经纬度切片)' },
|
{ value: 'vector-label-mercator', label: '天地图矢量注记(Web 墨卡托切片)' },
|
{ value: 'custom', label: '自定义瓦片地址' }
|
]
|
const TILE_LEVELS = 19
|
const DOWNLOAD_CONCURRENCY = 6
|
const MAX_ZIP_INPUT_BYTES = 128 * 1024 * 1024
|
const MAX_TILE_COUNT = 200_000
|
const TILE_REQUEST_TIMEOUT_MS = 15_000
|
const TILE_REQUEST_MAX_ATTEMPTS = 3
|
const TDT_SUBDOMAINS = ['0', '1', '2', '3', '4', '5', '6', '7']
|
const TDT_GEOGRAPHIC_LEVELS = 18
|
const TDT_GEOGRAPHIC_MATRIX_LABELS = Array.from(
|
{ length: TDT_GEOGRAPHIC_LEVELS },
|
(_, index) => String(index + 1)
|
)
|
|
const cesiumContainer = ref(null)
|
const overviewContainer = ref(null)
|
const storedPreviewPreset = localStorage.getItem(PREVIEW_PRESET_STORAGE_KEY)
|
const previewPreset = ref(
|
storedPreviewPreset === 'custom' || PREVIEW_PRESETS[storedPreviewPreset]
|
? storedPreviewPreset
|
: 'imagery-mercator'
|
)
|
const previewUrl = ref(localStorage.getItem(PREVIEW_URL_STORAGE_KEY) || DEFAULT_TILE_URL)
|
const storedDownloadUrl = localStorage.getItem(DOWNLOAD_URL_STORAGE_KEY)
|
const storedDownloadPreset = localStorage.getItem(DOWNLOAD_PRESET_STORAGE_KEY)
|
const matchedDownloadPreset = Object.entries(DOWNLOAD_PRESETS)
|
.find(([, url]) => url === storedDownloadUrl)?.[0]
|
const initialDownloadPreset = DOWNLOAD_PRESETS[storedDownloadPreset]
|
? storedDownloadPreset
|
: matchedDownloadPreset || (
|
storedDownloadUrl && !getTileUrlValidationError(storedDownloadUrl)
|
? 'custom'
|
: 'imagery-mercator'
|
)
|
const downloadPreset = ref(initialDownloadPreset)
|
const downloadUrl = ref(
|
matchedDownloadPreset
|
? DOWNLOAD_PRESETS[matchedDownloadPreset]
|
: initialDownloadPreset === 'custom' ? storedDownloadUrl : DOWNLOAD_PRESETS[initialDownloadPreset]
|
)
|
const rect = ref(null)
|
const zoomMap = ref({})
|
const isShow = ref(false)
|
const isLoading = ref(false)
|
const isEditing = ref(false)
|
const isDrawing = ref(false)
|
const process = ref(0)
|
const draftPointCount = ref(0)
|
const lng = ref(115.4692848)
|
const lat = ref(28.3481129)
|
const zoom = ref(12)
|
const rule = ref('tiles/[z]/[x]/[y].[ext]')
|
|
let viewer = null
|
let overviewViewer = null
|
let previewLayers = []
|
let rectEntity = null
|
let overviewRectEntity = null
|
let inputHandler = null
|
let overviewInputHandler = null
|
let draftPolygonPoints = []
|
let draftFloatingPoint = null
|
let draftPolygonEntity = null
|
let draftPolylineEntity = null
|
let draftPointEntities = []
|
let activeHandleRole = null
|
let currentViewRect = null
|
let overviewDragStart = null
|
let mainCameraDragStart = null
|
let isOverviewDragging = false
|
const handleRoles = [
|
'bottom-left', 'left', 'top-left', 'top',
|
'top-right', 'right', 'bottom-right', 'bottom'
|
]
|
const handleEntities = new Map()
|
|
const operationStatus = computed(() => {
|
if (isDrawing.value) {
|
if (!draftPointCount.value) return '单击增加范围点'
|
if (draftPointCount.value < 3) return '继续单击增加范围点,右击点位可删除'
|
return '继续增加范围点,或点击最后一个点完成绘制'
|
}
|
if (isEditing.value) return '范围编辑中'
|
return '点击“划范围”后在地图上选择两个角点'
|
})
|
|
const tableData = computed(() => {
|
if (!rect.value) return []
|
const geographic = isGeographicTileSource(downloadUrl.value)
|
const length = geographic ? TDT_GEOGRAPHIC_LEVELS : TILE_LEVELS
|
return Array.from({ length }, (_, index) => ({
|
level: geographic ? index + 1 : index,
|
num: getTileCount(index)
|
}))
|
})
|
|
const centerLnglat = computed(() => {
|
if (!rect.value) return ''
|
return [
|
(rect.value[0] + rect.value[2]) / 2,
|
(rect.value[1] + rect.value[3]) / 2
|
].toString()
|
})
|
|
const rectLngLat = computed(() => {
|
if (!rect.value) return ''
|
return `左上角: ${rect.value[0]},${rect.value[3]} 右下角: ${rect.value[2]},${rect.value[1]}`
|
})
|
|
onMounted(() => {
|
initViewers()
|
})
|
|
onUnmounted(() => {
|
inputHandler?.destroy()
|
overviewInputHandler?.destroy()
|
viewer?.destroy()
|
overviewViewer?.destroy()
|
inputHandler = null
|
overviewInputHandler = null
|
viewer = null
|
overviewViewer = null
|
})
|
|
function createViewer (container, interactive) {
|
const instance = new Viewer(container, {
|
animation: false,
|
baseLayer: false,
|
baseLayerPicker: false,
|
fullscreenButton: false,
|
geocoder: false,
|
homeButton: false,
|
infoBox: false,
|
navigationHelpButton: false,
|
sceneMode: SceneMode.SCENE3D,
|
sceneModePicker: false,
|
selectionIndicator: false,
|
timeline: false,
|
shouldAnimate: false
|
})
|
if (!interactive) {
|
instance.scene.screenSpaceCameraController.enableRotate = false
|
instance.scene.screenSpaceCameraController.enableTilt = false
|
instance.scene.screenSpaceCameraController.enableLook = false
|
instance.scene.screenSpaceCameraController.enableTranslate = false
|
instance.scene.screenSpaceCameraController.enableZoom = false
|
}
|
return instance
|
}
|
|
function createImageryProvider (url) {
|
if (isGeographicTileSource(url)) {
|
if (isWmtsTileUrl(url)) {
|
return new WebMapTileServiceImageryProvider({
|
url,
|
layer: getUrlParameter(url, 'layer') || 'img',
|
style: getUrlParameter(url, 'style') || 'default',
|
format: getUrlParameter(url, 'format') || 'tiles',
|
tileMatrixSetID: getUrlParameter(url, 'tileMatrixSet') || 'c',
|
subdomains: TDT_SUBDOMAINS,
|
tilingScheme: new GeographicTilingScheme(),
|
tileMatrixLabels: TDT_GEOGRAPHIC_MATRIX_LABELS,
|
minimumLevel: 0,
|
maximumLevel: 17,
|
credit: '天地图'
|
})
|
}
|
return new UrlTemplateImageryProvider({
|
url: url.replaceAll('{z}', '{tdtLevel}'),
|
subdomains: TDT_SUBDOMAINS,
|
tilingScheme: new GeographicTilingScheme(),
|
customTags: {
|
tdtLevel: (_provider, _x, _y, level) => level + 1
|
},
|
minimumLevel: 0,
|
maximumLevel: 17,
|
credit: '天地图'
|
})
|
}
|
return new UrlTemplateImageryProvider({
|
url,
|
subdomains: TDT_SUBDOMAINS,
|
minimumLevel: 0,
|
maximumLevel: 18,
|
credit: '天地图'
|
})
|
}
|
|
function isGeographicTileSource (url) {
|
return /(?:T=|\/)(?:[^/?&#]*_c)(?:[/?&#]|$)/i.test(url) || /[?&]tileMatrixSet=c(?:&|$)/i.test(url)
|
}
|
|
function isWmtsTileUrl (url) {
|
return /\{TileMatrix\}|\{TileRow\}|\{TileCol\}/i.test(url)
|
}
|
|
function getUrlParameter (url, name) {
|
const match = url.match(new RegExp(`[?&]${name}=([^&#]+)`, 'i'))
|
return match ? decodeURIComponent(match[1]) : ''
|
}
|
|
function initViewers () {
|
viewer = createViewer(cesiumContainer.value, true)
|
overviewViewer = createViewer(overviewContainer.value, false)
|
viewer.cesiumWidget.screenSpaceEventHandler.removeInputAction(
|
ScreenSpaceEventType.LEFT_DOUBLE_CLICK
|
)
|
replacePreviewLayers()
|
overviewViewer.imageryLayers.addImageryProvider(createImageryProvider(DEFAULT_TILE_URL))
|
|
viewer.camera.setView({
|
destination: Rectangle.fromDegrees(114.9, 27.95, 116.05, 28.75)
|
})
|
installInputHandler()
|
ensureRectangleEntities()
|
installOverviewInputHandler()
|
viewer.camera.percentageChanged = 0.01
|
viewer.camera.changed.addEventListener(syncCameraState)
|
syncCameraState()
|
}
|
|
function ensureRectangleEntities () {
|
rectEntity = viewer.entities.add({
|
rectangle: {
|
coordinates: new CallbackProperty(() => toCesiumRectangle(rect.value), false),
|
material: new ColorMaterialProperty(new CallbackProperty(
|
() => isEditing.value ? Color.ORANGE.withAlpha(0.18) : Color.RED.withAlpha(0.14), false
|
)),
|
outline: true,
|
outlineColor: new CallbackProperty(() => isEditing.value ? Color.ORANGE : Color.RED, false),
|
outlineWidth: 3
|
}
|
})
|
overviewRectEntity = overviewViewer.entities.add({
|
rectangle: {
|
coordinates: new CallbackProperty(() => currentViewRect, false),
|
fill: false,
|
outline: true,
|
outlineColor: Color.RED,
|
outlineWidth: 2
|
}
|
})
|
}
|
|
function installInputHandler () {
|
inputHandler = new ScreenSpaceEventHandler(viewer.scene.canvas)
|
inputHandler.setInputAction((movement) => {
|
if (!isDrawing.value) return
|
handlePolygonDrawClick(movement.position)
|
}, ScreenSpaceEventType.LEFT_CLICK)
|
|
inputHandler.setInputAction((movement) => {
|
if (isDrawing.value) return
|
const coordinate = screenToLonLat(movement.position)
|
if (!coordinate) return
|
if (!isEditing.value) return
|
const picked = viewer.scene.pick(movement.position)
|
const role = picked?.id ? findHandleRole(picked.id) : null
|
if (!role) return
|
activeHandleRole = role
|
viewer.scene.screenSpaceCameraController.enableTranslate = false
|
viewer.scene.screenSpaceCameraController.enableRotate = false
|
}, ScreenSpaceEventType.LEFT_DOWN)
|
|
inputHandler.setInputAction((movement) => {
|
const coordinate = screenToLonLat(movement.endPosition)
|
if (isDrawing.value && draftPolygonPoints.length && coordinate) {
|
draftFloatingPoint = coordinate
|
return
|
}
|
if (activeHandleRole && coordinate) {
|
applyHandleCoordinate(activeHandleRole, coordinate)
|
}
|
}, ScreenSpaceEventType.MOUSE_MOVE)
|
|
inputHandler.setInputAction(() => {
|
activeHandleRole = null
|
if (viewer) {
|
viewer.scene.screenSpaceCameraController.enableTranslate = true
|
viewer.scene.screenSpaceCameraController.enableRotate = true
|
}
|
}, ScreenSpaceEventType.LEFT_UP)
|
|
inputHandler.setInputAction((movement) => {
|
if (!isDrawing.value) return
|
const picked = viewer.scene.pick(movement.position)?.id
|
const index = draftPointEntities.indexOf(picked)
|
if (index < 0) return
|
draftPolygonPoints.splice(index, 1)
|
draftFloatingPoint = draftPolygonPoints.at(-1) || null
|
rebuildDraftPointEntities()
|
}, ScreenSpaceEventType.RIGHT_CLICK)
|
}
|
|
function installOverviewInputHandler () {
|
overviewInputHandler = new ScreenSpaceEventHandler(overviewViewer.scene.canvas)
|
overviewViewer.scene.canvas.style.cursor = 'grab'
|
|
overviewInputHandler.setInputAction((movement) => {
|
const coordinate = screenToCartographic(overviewViewer, movement.position)
|
if (!coordinate) return
|
const cameraPosition = viewer.camera.positionCartographic
|
overviewDragStart = coordinate
|
mainCameraDragStart = {
|
longitude: cameraPosition.longitude,
|
latitude: cameraPosition.latitude,
|
height: cameraPosition.height,
|
heading: viewer.camera.heading,
|
pitch: viewer.camera.pitch,
|
roll: viewer.camera.roll
|
}
|
isOverviewDragging = true
|
overviewViewer.scene.canvas.style.cursor = 'grabbing'
|
}, ScreenSpaceEventType.LEFT_DOWN)
|
|
overviewInputHandler.setInputAction((movement) => {
|
if (!isOverviewDragging || !overviewDragStart || !mainCameraDragStart) return
|
const coordinate = screenToCartographic(overviewViewer, movement.endPosition)
|
if (!coordinate) return
|
const longitude = wrapLongitude(
|
mainCameraDragStart.longitude + coordinate.longitude - overviewDragStart.longitude
|
)
|
const latitude = Math.max(
|
-CesiumMath.PI_OVER_TWO + 1e-6,
|
Math.min(
|
CesiumMath.PI_OVER_TWO - 1e-6,
|
mainCameraDragStart.latitude + coordinate.latitude - overviewDragStart.latitude
|
)
|
)
|
viewer.camera.setView({
|
destination: Cartesian3.fromRadians(longitude, latitude, mainCameraDragStart.height),
|
orientation: {
|
heading: mainCameraDragStart.heading,
|
pitch: mainCameraDragStart.pitch,
|
roll: mainCameraDragStart.roll
|
}
|
})
|
syncCameraState()
|
}, ScreenSpaceEventType.MOUSE_MOVE)
|
|
overviewInputHandler.setInputAction(stopOverviewDragging, ScreenSpaceEventType.LEFT_UP)
|
}
|
|
function stopOverviewDragging () {
|
if (!isOverviewDragging) return
|
isOverviewDragging = false
|
overviewDragStart = null
|
mainCameraDragStart = null
|
overviewViewer.scene.canvas.style.cursor = 'grab'
|
const viewRect = viewer.camera.computeViewRectangle(viewer.scene.globe.ellipsoid)
|
if (viewRect) syncOverview(viewRect)
|
}
|
|
function wrapLongitude (longitude) {
|
return CesiumMath.negativePiToPi(longitude)
|
}
|
|
function handlePolygonDrawClick (position) {
|
const picked = viewer.scene.pick(position)?.id
|
const pointIndex = draftPointEntities.indexOf(picked)
|
if (pointIndex === draftPolygonPoints.length - 1 && draftPolygonPoints.length >= 3) {
|
finishPolygonDrawing()
|
return
|
}
|
if (pointIndex >= 0) return
|
const coordinate = screenToLonLat(position)
|
if (!coordinate) return
|
draftPolygonPoints.push(coordinate)
|
draftFloatingPoint = coordinate
|
ensureDraftPolygonEntities()
|
rebuildDraftPointEntities()
|
}
|
|
function getDraftPreviewPoints () {
|
if (!draftPolygonPoints.length) return []
|
return draftFloatingPoint
|
? [...draftPolygonPoints, draftFloatingPoint]
|
: draftPolygonPoints
|
}
|
|
function getDraftPreviewCartesians () {
|
return getDraftPreviewPoints().map(([longitude, latitude]) => Cartesian3.fromDegrees(longitude, latitude))
|
}
|
|
function ensureDraftPolygonEntities () {
|
if (draftPolygonEntity) return
|
draftPolygonEntity = viewer.entities.add({
|
polygon: {
|
hierarchy: new CallbackProperty(() => new PolygonHierarchy(getDraftPreviewCartesians()), false),
|
material: Color.DODGERBLUE.withAlpha(0.28),
|
show: new CallbackProperty(() => getDraftPreviewPoints().length >= 3, false)
|
}
|
})
|
draftPolylineEntity = viewer.entities.add({
|
polyline: {
|
positions: new CallbackProperty(() => {
|
const positions = getDraftPreviewCartesians()
|
return positions.length >= 3 ? [...positions, positions[0]] : positions
|
}, false),
|
clampToGround: true,
|
width: 3,
|
material: Color.DODGERBLUE,
|
show: new CallbackProperty(() => getDraftPreviewPoints().length >= 2, false)
|
}
|
})
|
}
|
|
function rebuildDraftPointEntities () {
|
draftPointEntities.forEach((entity) => viewer.entities.remove(entity))
|
draftPointCount.value = draftPolygonPoints.length
|
draftPointEntities = draftPolygonPoints.map(([longitude, latitude]) => viewer.entities.add({
|
position: Cartesian3.fromDegrees(longitude, latitude),
|
point: {
|
pixelSize: 12,
|
color: Color.WHITE,
|
outlineColor: Color.DODGERBLUE,
|
outlineWidth: 3,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
}
|
}))
|
}
|
|
function finishPolygonDrawing () {
|
const longitudes = draftPolygonPoints.map((point) => point[0])
|
const latitudes = draftPolygonPoints.map((point) => point[1])
|
rect.value = [
|
Math.min(...longitudes),
|
Math.min(...latitudes),
|
Math.max(...longitudes),
|
Math.max(...latitudes)
|
]
|
isDrawing.value = false
|
clearDraftPolygon()
|
}
|
|
function clearDraftPolygon () {
|
if (draftPolygonEntity) viewer?.entities.remove(draftPolygonEntity)
|
if (draftPolylineEntity) viewer?.entities.remove(draftPolylineEntity)
|
draftPointEntities.forEach((entity) => viewer?.entities.remove(entity))
|
draftPolygonPoints = []
|
draftFloatingPoint = null
|
draftPolygonEntity = null
|
draftPolylineEntity = null
|
draftPointEntities = []
|
draftPointCount.value = 0
|
}
|
|
function startDrawing () {
|
stopEditing()
|
clearDraftPolygon()
|
rect.value = null
|
zoomMap.value = {}
|
isDrawing.value = true
|
}
|
|
function clearRect () {
|
stopEditing()
|
isDrawing.value = false
|
clearDraftPolygon()
|
rect.value = null
|
}
|
|
function startEditing () {
|
if (!rect.value || isEditing.value) return
|
isDrawing.value = false
|
isEditing.value = true
|
addEditHandles()
|
}
|
|
function stopEditing () {
|
isEditing.value = false
|
activeHandleRole = null
|
handleEntities.forEach((entity) => viewer?.entities.remove(entity))
|
handleEntities.clear()
|
if (viewer) {
|
viewer.scene.screenSpaceCameraController.enableTranslate = true
|
viewer.scene.screenSpaceCameraController.enableRotate = true
|
}
|
}
|
|
function addEditHandles () {
|
handleRoles.forEach((role) => {
|
const entity = viewer.entities.add({
|
position: new CallbackProperty(() => {
|
const coordinate = getHandleCoordinates()[role]
|
return Cartesian3.fromDegrees(coordinate[0], coordinate[1])
|
}, false),
|
point: {
|
pixelSize: 12,
|
color: Color.WHITE,
|
outlineColor: Color.ORANGE,
|
outlineWidth: 3,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
}
|
})
|
handleEntities.set(role, entity)
|
})
|
}
|
|
function findHandleRole (entity) {
|
for (const [role, handleEntity] of handleEntities) {
|
if (handleEntity === entity) return role
|
}
|
return null
|
}
|
|
function getHandleCoordinates () {
|
const [left, bottom, right, top] = rect.value
|
const centerX = (left + right) / 2
|
const centerY = (bottom + top) / 2
|
return {
|
'bottom-left': [left, bottom],
|
left: [left, centerY],
|
'top-left': [left, top],
|
top: [centerX, top],
|
'top-right': [right, top],
|
right: [right, centerY],
|
'bottom-right': [right, bottom],
|
bottom: [centerX, bottom]
|
}
|
}
|
|
function applyHandleCoordinate (role, coordinate) {
|
const [left, bottom, right, top] = rect.value
|
const minSize = 1e-8
|
let nextLeft = left
|
let nextBottom = bottom
|
let nextRight = right
|
let nextTop = top
|
if (role.includes('left')) nextLeft = Math.min(coordinate[0], right - minSize)
|
if (role.includes('right')) nextRight = Math.max(coordinate[0], left + minSize)
|
if (role.includes('top')) nextTop = Math.max(coordinate[1], bottom + minSize)
|
if (role.includes('bottom')) nextBottom = Math.min(coordinate[1], top - minSize)
|
rect.value = [nextLeft, nextBottom, nextRight, nextTop]
|
}
|
|
function screenToCartographic (targetViewer, position) {
|
const cartesian = targetViewer.camera.pickEllipsoid(position, targetViewer.scene.globe.ellipsoid)
|
if (!cartesian) return null
|
return Cartographic.fromCartesian(cartesian)
|
}
|
|
function screenToLonLat (position) {
|
const cartographic = screenToCartographic(viewer, position)
|
if (!cartographic) return null
|
return [
|
CesiumMath.toDegrees(cartographic.longitude),
|
CesiumMath.toDegrees(cartographic.latitude)
|
]
|
}
|
|
function toCesiumRectangle (value) {
|
return value ? Rectangle.fromDegrees(value[0], value[1], value[2], value[3]) : undefined
|
}
|
|
function syncCameraState () {
|
const viewRect = viewer.camera.computeViewRectangle(viewer.scene.globe.ellipsoid)
|
if (!viewRect) return
|
currentViewRect = Rectangle.clone(viewRect)
|
const center = Rectangle.center(viewRect)
|
lng.value = Number(CesiumMath.toDegrees(center.longitude).toFixed(8))
|
lat.value = Number(CesiumMath.toDegrees(center.latitude).toFixed(8))
|
const degreesPerPixel = CesiumMath.toDegrees(viewRect.width) / Math.max(viewer.canvas.clientWidth, 1)
|
zoom.value = Math.max(0, Math.min(18, Math.round(Math.log2(360 / 256 / degreesPerPixel))))
|
if (!isOverviewDragging) syncOverview(viewRect)
|
}
|
|
function syncOverview (viewRect) {
|
const center = Rectangle.center(viewRect)
|
const width = Math.min(viewRect.width * 4, Math.PI * 2)
|
const height = Math.min(viewRect.height * 4, Math.PI)
|
const expanded = new Rectangle(
|
Math.max(-Math.PI, center.longitude - width / 2),
|
Math.max(-Math.PI / 2, center.latitude - height / 2),
|
Math.min(Math.PI, center.longitude + width / 2),
|
Math.min(Math.PI / 2, center.latitude + height / 2)
|
)
|
overviewViewer.camera.setView({ destination: expanded })
|
}
|
|
function isStructurallyValidTileUrl (url) {
|
if (!url) return false
|
const xyz = ['{x}', '{y}', '{z}'].every((placeholder) => url.includes(placeholder))
|
const wmts = ['{TileCol}', '{TileRow}', '{TileMatrix}'].every((placeholder) => url.includes(placeholder))
|
return xyz || wmts
|
}
|
|
function getTileUrlValidationError (url) {
|
if (!isStructurallyValidTileUrl(url)) {
|
return '地址必须包含 {x}、{y}、{z},或完整的 WMTS 瓦片占位符'
|
}
|
if (/tianditu\.gov\.cn\/DataServer/i.test(url)) {
|
const type = url.match(/[?&]T=([^&#]*)/i)?.[1]
|
if (!type || !/^[a-z0-9]+_[cw]$/i.test(type)) {
|
return '天地图 T 参数不完整,例如影像应填写 img_w 或 img_c'
|
}
|
}
|
return ''
|
}
|
|
function savePreviewUrl () {
|
localStorage.setItem(PREVIEW_PRESET_STORAGE_KEY, previewPreset.value)
|
if (previewPreset.value !== 'custom') return true
|
const error = getTileUrlValidationError(previewUrl.value)
|
if (error) {
|
ElMessage.warning(error)
|
return false
|
}
|
localStorage.setItem(PREVIEW_URL_STORAGE_KEY, previewUrl.value)
|
return true
|
}
|
|
function saveDownloadUrl () {
|
if (downloadPreset.value !== 'custom') {
|
downloadUrl.value = DOWNLOAD_PRESETS[downloadPreset.value]
|
}
|
const error = getTileUrlValidationError(downloadUrl.value)
|
if (error) {
|
ElMessage.warning(error)
|
return false
|
}
|
localStorage.setItem(DOWNLOAD_PRESET_STORAGE_KEY, downloadPreset.value)
|
localStorage.setItem(DOWNLOAD_URL_STORAGE_KEY, downloadUrl.value)
|
return true
|
}
|
|
function changeDownloadPreset () {
|
if (downloadPreset.value !== 'custom') {
|
downloadUrl.value = DOWNLOAD_PRESETS[downloadPreset.value]
|
}
|
zoomMap.value = {}
|
saveDownloadUrl()
|
}
|
|
function loadPreview () {
|
if (!savePreviewUrl()) return
|
replacePreviewLayers()
|
}
|
|
function replacePreviewLayers () {
|
for (const layer of previewLayers) viewer.imageryLayers.remove(layer, true)
|
const urls = previewPreset.value === 'custom'
|
? [previewUrl.value]
|
: PREVIEW_PRESETS[previewPreset.value] || PREVIEW_PRESETS['imagery-mercator']
|
previewLayers = urls.map((url) => viewer.imageryLayers.addImageryProvider(createImageryProvider(url)))
|
}
|
|
function lon2tile (longitude, level, geographic = isGeographicTileSource(downloadUrl.value)) {
|
const normalized = Math.min(180, Math.max(-180, longitude))
|
const columns = Math.pow(2, geographic ? level + 1 : level)
|
return Math.min(columns - 1, Math.floor((normalized + 180) / 360 * columns))
|
}
|
|
function lat2tile (latitude, level, geographic = isGeographicTileSource(downloadUrl.value)) {
|
if (geographic) {
|
const normalized = Math.min(90, Math.max(-90, latitude))
|
const rows = Math.pow(2, level)
|
return Math.min(rows - 1, Math.max(0, Math.floor((90 - normalized) / 180 * rows)))
|
}
|
const normalized = Math.min(85.05112878, Math.max(-85.05112878, latitude))
|
const radians = normalized * Math.PI / 180
|
const rows = Math.pow(2, level)
|
const y = Math.floor((1 - Math.log(Math.tan(radians) + 1 / Math.cos(radians)) / Math.PI) / 2 * rows)
|
return Math.min(rows - 1, Math.max(0, y))
|
}
|
|
function getTileCount (level) {
|
const xMin = lon2tile(rect.value[0], level)
|
const yMin = lat2tile(rect.value[3], level)
|
const xMax = lon2tile(rect.value[2], level)
|
const yMax = lat2tile(rect.value[1], level)
|
return (xMax - xMin + 1) * (yMax - yMin + 1)
|
}
|
|
function * createTileIterator (selectedLevels) {
|
const geographic = isGeographicTileSource(downloadUrl.value)
|
for (const sourceLevel of selectedLevels) {
|
const calculationLevel = geographic ? sourceLevel - 1 : sourceLevel
|
const xMin = lon2tile(rect.value[0], calculationLevel, geographic)
|
const yMin = lat2tile(rect.value[3], calculationLevel, geographic)
|
const xMax = lon2tile(rect.value[2], calculationLevel, geographic)
|
const yMax = lat2tile(rect.value[1], calculationLevel, geographic)
|
for (let x = xMin; x <= xMax; x++) {
|
for (let y = yMin; y <= yMax; y++) yield { x, y, z: sourceLevel }
|
}
|
}
|
}
|
|
function getImageExtension (contentType) {
|
const type = contentType.split(';')[0].trim().toLowerCase()
|
return {
|
'image/jpeg': 'jpg',
|
'image/png': 'png',
|
'image/webp': 'webp',
|
'image/gif': 'gif'
|
}[type] || 'png'
|
}
|
|
async function downloadTile (x, y, z) {
|
const subdomain = TDT_SUBDOMAINS[Math.abs(x + y) % TDT_SUBDOMAINS.length]
|
const replacements = {
|
'{x}': x,
|
'{y}': y,
|
'{z}': z,
|
'{TileCol}': x,
|
'{TileRow}': y,
|
'{TileMatrix}': z,
|
'{s}': subdomain
|
}
|
const tileUrl = Object.entries(replacements).reduce(
|
(result, [placeholder, value]) => result.replaceAll(placeholder, value),
|
downloadUrl.value
|
)
|
let lastError
|
for (let attempt = 1; attempt <= TILE_REQUEST_MAX_ATTEMPTS; attempt++) {
|
const controller = new AbortController()
|
const timeoutId = setTimeout(() => controller.abort(), TILE_REQUEST_TIMEOUT_MS)
|
try {
|
const response = await fetch(tileUrl, { signal: controller.signal })
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
const contentType = response.headers.get('content-type') || ''
|
if (contentType && !contentType.startsWith('image/') && !contentType.includes('octet-stream')) {
|
throw new Error(`响应不是图片:${contentType}`)
|
}
|
return { blob: await response.blob(), extension: getImageExtension(contentType) }
|
} catch (error) {
|
lastError = error
|
if (attempt < TILE_REQUEST_MAX_ATTEMPTS) {
|
await new Promise((resolve) => setTimeout(resolve, attempt * 500))
|
}
|
} finally {
|
clearTimeout(timeoutId)
|
}
|
}
|
throw new Error(`瓦片 ${z}/${x}/${y} 下载失败:${lastError?.message || lastError}`)
|
}
|
|
async function download () {
|
if (!saveDownloadUrl()) return
|
const selectedRows = tableData.value.filter((row) => zoomMap.value[row.level])
|
const selectedLevels = selectedRows.map((row) => row.level)
|
const total = selectedRows.reduce((sum, row) => sum + row.num, 0)
|
if (!total) {
|
ElMessage.warning('请至少选择一个缩放级别')
|
return
|
}
|
if (total > MAX_TILE_COUNT) {
|
ElMessage.error(`瓦片数量 ${total} 超过单次上限 ${MAX_TILE_COUNT}`)
|
return
|
}
|
try {
|
await ElMessageBox.confirm(`确定下载选中的 ${total} 个瓦片吗?`, '提示', {
|
confirmButtonText: '确定',
|
cancelButtonText: '取消',
|
type: 'warning'
|
})
|
} catch {
|
return
|
}
|
isShow.value = false
|
const result = await downloadTiles(createTileIterator(selectedLevels), total)
|
if (result.failed.length) {
|
saveBlob(new Blob([result.failed.join('\n')], { type: 'text/plain;charset=utf-8' }), 'failed-tiles.txt')
|
ElMessage.warning(`下载完成:成功 ${result.succeeded},失败 ${result.failed.length}`)
|
} else {
|
ElMessage.success(`下载完成,共 ${result.succeeded} 个瓦片`)
|
}
|
}
|
|
function saveBlob (blob, filename) {
|
const objectUrl = URL.createObjectURL(blob)
|
const link = document.createElement('a')
|
link.href = objectUrl
|
link.download = filename
|
document.body.appendChild(link)
|
link.click()
|
link.remove()
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 5_000)
|
}
|
|
async function downloadTiles (iterator, total) {
|
isLoading.value = true
|
process.value = 0
|
let count = 0
|
let succeeded = 0
|
const failed = []
|
let part = 1
|
let zip = new JSZip()
|
let zipInputBytes = 0
|
|
const flushZip = async () => {
|
if (!zipInputBytes) return
|
const content = await zip.generateAsync({ type: 'blob', compression: 'STORE', streamFiles: true })
|
saveBlob(content, `tiles-part-${String(part).padStart(3, '0')}.zip`)
|
part++
|
zip = new JSZip()
|
zipInputBytes = 0
|
}
|
|
try {
|
while (true) {
|
const batch = []
|
for (let i = 0; i < DOWNLOAD_CONCURRENCY; i++) {
|
const next = iterator.next()
|
if (next.done) break
|
batch.push(next.value)
|
}
|
if (!batch.length) break
|
const results = await Promise.all(batch.map(async (item) => {
|
try {
|
return { item, tile: await downloadTile(item.x, item.y, item.z) }
|
} catch (error) {
|
return { item, error }
|
}
|
}))
|
for (const result of results) {
|
count++
|
if (result.error) {
|
failed.push(`${result.item.z}/${result.item.x}/${result.item.y}\t${result.error.message}`)
|
} else {
|
const { item, tile } = result
|
zip.file(`${item.z}/${item.x}/${item.y}.${tile.extension}`, tile.blob)
|
zipInputBytes += tile.blob.size
|
succeeded++
|
}
|
process.value = ((count / total) * 100).toFixed(2)
|
}
|
if (zipInputBytes >= MAX_ZIP_INPUT_BYTES) await flushZip()
|
}
|
await flushZip()
|
return { succeeded, failed }
|
} finally {
|
isLoading.value = false
|
}
|
}
|
</script>
|
|
<style lang="scss" scoped>
|
.map-downloader {
|
position: absolute;
|
inset: 0;
|
overflow: hidden;
|
color: #171717;
|
}
|
|
.cesium-map {
|
width: 100%;
|
height: 100%;
|
}
|
|
:deep(.cesium-widget-credits) {
|
display: none !important;
|
}
|
|
.toolbar {
|
position: fixed;
|
z-index: 5;
|
top: 100px;
|
left: 50%;
|
display: flex;
|
width: min(1000px, calc(100% - 32px));
|
height: 40px;
|
transform: translateX(-50%);
|
}
|
|
.source-select,
|
.source-input {
|
height: 40px;
|
min-width: 0;
|
border: 1px solid #1686df;
|
padding: 0 10px;
|
background: #fff;
|
outline: none;
|
font-size: 15px;
|
}
|
|
.source-select {
|
width: 360px;
|
}
|
|
.source-input {
|
flex: 1;
|
}
|
|
button {
|
min-width: 100px;
|
height: 40px;
|
border: 0;
|
margin-left: 8px;
|
background: #1686df;
|
color: #fff;
|
cursor: pointer;
|
|
&:disabled {
|
cursor: not-allowed;
|
opacity: 0.5;
|
}
|
|
&.is-editing {
|
background: #ef8200;
|
opacity: 1;
|
}
|
}
|
|
.range-tools {
|
position: fixed;
|
z-index: 5;
|
top: 40%;
|
left: 0;
|
display: flex;
|
width: 108px;
|
flex-direction: column;
|
gap: 8px;
|
|
button {
|
width: 108px;
|
margin: 0;
|
}
|
}
|
|
.overview-map {
|
position: fixed;
|
z-index: 4;
|
bottom: 40px;
|
left: 8px;
|
width: 180px;
|
height: 130px;
|
border: 1px solid rgba(0, 0, 0, 0.65);
|
background: #fff;
|
}
|
|
.status-bar {
|
position: fixed;
|
z-index: 5;
|
bottom: 0;
|
left: 0;
|
display: flex;
|
width: 100%;
|
min-height: 32px;
|
align-items: center;
|
justify-content: center;
|
gap: 14px;
|
padding: 6px 12px;
|
background: rgba(0, 0, 0, 0.82);
|
color: #fff;
|
font-size: 13px;
|
}
|
|
.loading-mask {
|
position: fixed;
|
z-index: 9999;
|
inset: 0;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
background: rgba(0, 0, 0, 0.58);
|
color: #fff;
|
font-size: 18px;
|
}
|
|
@media (max-width: 760px) {
|
.toolbar {
|
top: 12px;
|
height: auto;
|
flex-wrap: wrap;
|
gap: 6px;
|
}
|
|
.source-select,
|
.source-input {
|
width: 100%;
|
flex-basis: 100%;
|
}
|
|
.toolbar button {
|
flex: 1;
|
margin: 0;
|
}
|
|
.status-bar {
|
align-items: flex-start;
|
flex-direction: column;
|
gap: 2px;
|
}
|
|
.overview-map {
|
bottom: 104px;
|
width: 140px;
|
height: 100px;
|
}
|
}
|
</style>
|