Files
geo-tools/src/components/CoordinateConverter.vue
missum be48495dcf feat: add map interaction and coordinate plotter tools, optimize dev config
1. 新增图上量测与拾取、坐标展点两个地图工具页面
2. 安装leaflet依赖并配置vite开发服务器允许局域网访问
3. 更新首页工具列表,新增两个工具入口
4. 优化坐标转换器默认参数和文件读取方式
5. 调整面积计算精度,删除无用的HelloWorld组件
2026-06-08 09:37:20 +08:00

710 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="container pb-2xl">
<div class="tool-header">
<router-link to="/" class="back-link">
<ChevronLeft class="icon-sm" /> 返回首页
</router-link>
<h2>
<Globe class="icon-lg" /> 坐标系统与格式转换
</h2>
<p>支持经纬度格式转换常用地图坐标系互转及 CGCS2000 高斯平面投影与批量转换</p>
</div>
<div class="tabs-wrapper">
<!-- Tab Navigation -->
<div class="tabs-header">
<button
v-for="tab in tabsList"
:key="tab.id"
@click="activeTab = tab.id"
:class="['tab-btn', { active: activeTab === tab.id }]"
>
<component :is="tab.icon" class="icon-sm mr-xs" />
{{ tab.name }}
</button>
</div>
<!-- Tab Content -->
<div class="tab-content card">
<!-- 1. Format Conversion -->
<div v-show="activeTab === 'format'">
<h3 class="card-title text-center mb-xl">度分秒与十进制度数互转</h3>
<div class="grid grid-2 gap-xl">
<div class="flex-col">
<div class="input-group flex-1">
<label class="input-label">度分秒 十进制</label>
<div class="dms-input mb-sm">
<input type="number" v-model.number="dms.lat.degrees" class="input" placeholder="度">
<input type="number" v-model.number="dms.lat.minutes" class="input" placeholder="分">
<input type="number" v-model.number="dms.lat.seconds" class="input" placeholder="秒">
<select v-model="dms.lat.direction" class="input px-xs">
<option value="N">N</option>
<option value="S">S</option>
</select>
</div>
<div class="dms-input">
<input type="number" v-model.number="dms.lon.degrees" class="input" placeholder="度">
<input type="number" v-model.number="dms.lon.minutes" class="input" placeholder="分">
<input type="number" v-model.number="dms.lon.seconds" class="input" placeholder="秒">
<select v-model="dms.lon.direction" class="input px-xs">
<option value="E">E</option>
<option value="W">W</option>
</select>
</div>
</div>
<button @click="convertToDecimal" class="btn btn-primary btn-full mt-md">转换为十进制</button>
</div>
<div class="flex-col">
<div class="input-group flex-1">
<label class="input-label">十进制 度分秒</label>
<div class="flex flex-col gap-sm">
<input type="number" v-model.number="decimal.lat" class="input" placeholder="纬度 (Lat)">
<input type="number" v-model.number="decimal.lon" class="input" placeholder="经度 (Lon)">
</div>
</div>
<button @click="convertToDms" class="btn btn-primary btn-full mt-md">转换为度分秒</button>
</div>
</div>
</div>
<!-- 2. CRS Transformation -->
<div v-show="activeTab === 'crs'">
<h3 class="card-title text-center mb-xl">常用地图坐标系互转</h3>
<div class="max-w-3xl mx-auto">
<div class="input-group">
<label class="input-label">输入坐标 (经度, 纬度)</label>
<input type="text" v-model="crsInput" class="input" placeholder="例: 116.397, 39.908">
</div>
<div class="grid grid-2 gap-md mt-md">
<div class="input-group">
<label class="input-label">来源坐标系</label>
<select v-model="sourceCrs" class="input">
<option value="WGS84">WGS84 (大地坐标/GPS)</option>
<option value="GCJ02">GCJ02 (火星坐标/高德/腾讯)</option>
<option value="BD09">BD09 (百度坐标)</option>
</select>
</div>
<div class="input-group">
<label class="input-label">目标坐标系</label>
<select v-model="targetCrs" class="input">
<option value="GCJ02">GCJ02 (火星坐标/高德/腾讯)</option>
<option value="WGS84">WGS84 (大地坐标/GPS)</option>
<option value="BD09">BD09 (百度坐标)</option>
</select>
</div>
</div>
<button @click="transformCrs" class="btn btn-primary btn-full mt-lg">执行地图坐标转换</button>
</div>
</div>
<!-- 3. CGCS2000 Projection -->
<div v-show="activeTab === 'proj'">
<h3 class="card-title text-center mb-xl">高斯-克吕格平面投影计算</h3>
<div class="max-w-3xl mx-auto">
<div class="grid grid-2 gap-md mb-md">
<div class="input-group">
<label class="input-label">转换方向</label>
<select v-model="projConfig.direction" class="input">
<option value="toPlane">经纬度 平面坐标</option>
<option value="toLngLat">平面坐标 经纬度</option>
</select>
</div>
<div class="input-group">
<label class="input-label">分带方式 (CGCS2000)</label>
<select v-model="projConfig.zoneType" class="input">
<option :value="3">3 度带</option>
<option :value="6">6 度带</option>
</select>
</div>
</div>
<div v-if="projConfig.direction === 'toPlane'" class="flex-col pb-md">
<div class="grid grid-2 gap-md mb-md">
<div class="input-group">
<label class="input-label">经度 (Lon)</label>
<input type="number" v-model.number="projConfig.lng" class="input" placeholder="输入经度">
</div>
<div class="input-group">
<label class="input-label">纬度 (Lat)</label>
<input type="number" v-model.number="projConfig.lat" class="input" placeholder="输入纬度">
</div>
</div>
<label class="checkbox-label mb-md">
<input type="checkbox" v-model="projConfig.withZone">
投影后的 X 坐标 (北移/纬距对应国内Y) 包含带号
</label>
<button @click="doProjection" class="btn btn-primary btn-full">投影到平面</button>
</div>
<div v-else class="flex-col pb-md">
<div class="grid grid-2 gap-md mb-md">
<div class="input-group">
<label class="input-label">北移坐标 (国内X)</label>
<input type="number" v-model.number="projConfig.y" class="input" placeholder="对应测量的 X 坐标 (Northing)">
</div>
<div class="input-group">
<label class="input-label">东移坐标 (国内Y)</label>
<input type="number" v-model.number="projConfig.x" class="input" placeholder="对应测量的 Y 坐标 (Easting)">
</div>
</div>
<div class="grid grid-2 gap-md mb-md align-center">
<label class="checkbox-label">
<input type="checkbox" v-model="projConfig.withZone"> 东移坐标包含带号
</label>
<div class="input-group m-0" v-if="!projConfig.withZone">
<input type="number" v-model.number="projConfig.manualZone" class="input" placeholder="请手动输入区域带号">
</div>
</div>
<button @click="doProjection" class="btn btn-primary btn-full">反投到经纬度</button>
</div>
</div>
</div>
<!-- 4. Execute Bulk Conversion -->
<div v-show="activeTab === 'batch'">
<h3 class="card-title text-center mb-md">通过 Excel 批量互转平面与地理坐标</h3>
<p class="text-secondary text-sm text-center mb-xl">
请先下载对应的格式模板填写数据后上传即可自动解析计算并导出所有计算均在浏览器本地完成
</p>
<div class="max-w-3xl mx-auto flex-col">
<div class="grid grid-2 gap-md mb-xl">
<button @click="downloadTemplate('toPlane')" class="btn btn-secondary flex-center gap-xs">
<DownloadCloud class="icon-sm" /> 下载 经纬度转平面 模板
</button>
<button @click="downloadTemplate('toLngLat')" class="btn btn-secondary flex-center gap-xs">
<DownloadCloud class="icon-sm" /> 下载 平面转经纬度 模板
</button>
<button @click="downloadTemplate('toDecimal')" class="btn btn-secondary flex-center gap-xs">
<DownloadCloud class="icon-sm" /> 下载 度分秒转十进制 模板
</button>
<button @click="downloadTemplate('toDms')" class="btn btn-secondary flex-center gap-xs">
<DownloadCloud class="icon-sm" /> 下载 十进制转度分秒 模板
</button>
</div>
<div class="upload-area">
<input type="file" id="excel-upload" accept=".xlsx, .xls" class="hidden" @change="handleFileUpload">
<label for="excel-upload" class="upload-label">
<UploadCloud class="icon-xl mb-md text-primary" />
<span class="text-lg font-600 mb-xs">点击此处选择 Excel 文件</span>
<span class="text-tertiary text-sm">支持 .xlsx, .xls 格式</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Single Result Area -->
<div v-if="result && activeTab !== 'batch'" class="card mt-xl">
<div class="flex justify-between items-center mb-md">
<h4 class="m-0 flex items-center gap-xs">
<CheckCircle2 class="icon-md text-success" /> 单点计算结果
</h4>
<button @click="copyResult" class="btn btn-secondary btn-sm flex-center gap-xs">
<Copy class="icon-sm" /> 复制
</button>
</div>
<div class="result-box">
<div v-if="result.type === 'decimal'">
<p><strong>纬度 (Lat):</strong> {{ result.lat }}</p>
<p><strong>经度 (Lon):</strong> {{ result.lon }}</p>
</div>
<div v-else-if="result.type === 'dms'">
<p><strong>纬度:</strong> {{ result.lat }}</p>
<p><strong>经度:</strong> {{ result.lon }}</p>
</div>
<div v-else-if="result.type === 'crs'">
<p><strong>经度 (Lon):</strong> {{ result.lon.toFixed(8) }}°</p>
<p><strong>纬度 (Lat):</strong> {{ result.lat.toFixed(8) }}°</p>
<small class="text-tertiary"> {{ sourceCrs }} 转换为 {{ targetCrs }}</small>
</div>
<div v-else-if="result.type === 'proj_plane'">
<p><strong>北移坐标 (国内X):</strong> {{ result.northing.toFixed(4) }} m</p>
<p><strong>东移坐标 (国内Y):</strong> {{ result.easting.toFixed(4) }} m</p>
<small class="text-tertiary">基于 CGCS2000 {{ projConfig.zoneType }}度带高斯投影 (带号: {{ result.zone }}, 中央子午线: {{ result.meridian }}°)</small>
</div>
<div v-else-if="result.type === 'proj_lnglat'">
<p><strong>经度 (Lon):</strong> {{ result.lng.toFixed(8) }}°</p>
<p><strong>纬度 (Lat):</strong> {{ result.lat.toFixed(8) }}°</p>
<small class="text-tertiary">基于 CGCS2000 {{ projConfig.zoneType }}度带高斯投影 (带号: {{ result.zone }}, 中央子午线: {{ result.meridian }}°)</small>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import gcoord from 'gcoord'
import * as xlsx from 'xlsx'
import { ChevronLeft, Globe, RefreshCw, MapPin, Copy, CheckCircle2, Layers, FileSpreadsheet, DownloadCloud, UploadCloud } from 'lucide-vue-next'
import { dmsToDecimal, formatCoordinate } from '../utils/coordinate'
import { projectToPlane, unprojectToLngLat } from '../utils/proj'
// === Tabs UI State ===
const activeTab = ref('format')
const tabsList = [
{ id: 'format', name: '格式转换', icon: RefreshCw },
{ id: 'crs', name: '地图坐标转换', icon: MapPin },
{ id: 'proj', name: 'CGCS2000投影转换', icon: Layers },
{ id: 'batch', name: 'Excel 批量互转', icon: FileSpreadsheet }
]
// === DMS Conversion State ===
const dms = ref({
lat: { degrees: 22, minutes: 49, seconds: 16.8640, direction: 'N' },
lon: { degrees: 108, minutes: 18, seconds: 37.4184, direction: 'E' }
})
const decimal = ref({ lat: 22.8213511, lon: 108.3103940 })
// === CRS Transformation State ===
const crsInput = ref('116.397451, 39.909235')
const sourceCrs = ref('WGS84')
const targetCrs = ref('GCJ02')
// === Projection State ===
const projConfig = ref({
direction: 'toPlane', // or 'toLngLat'
zoneType: 3, // 3 or 6
withZone: true,
lng: 108.55972199,
lat: 22.85808956,
x: 36557444.5725, // Easting (Y)
y: 2528930.9851, // Northing (X)
manualZone: null
})
const result = ref(null)
// --- Methods ---
const convertToDecimal = () => {
const latDecimal = dmsToDecimal(dms.value.lat.degrees, dms.value.lat.minutes, dms.value.lat.seconds)
const lonDecimal = dmsToDecimal(dms.value.lon.degrees, dms.value.lon.minutes, dms.value.lon.seconds)
result.value = {
type: 'decimal',
lat: (dms.value.lat.direction === 'S' ? -latDecimal : latDecimal).toFixed(8),
lon: (dms.value.lon.direction === 'W' ? -lonDecimal : lonDecimal).toFixed(8)
}
}
const convertToDms = () => {
result.value = {
type: 'dms',
lat: formatCoordinate(decimal.value.lat, 'lat'),
lon: formatCoordinate(decimal.value.lon, 'lon')
}
}
const transformCrs = () => {
const parts = crsInput.value.split(/[,\s]+/).map(p => parseFloat(p.trim())).filter(p => !isNaN(p))
if (parts.length < 2) {
alert('请输入有效的坐标 (经度, 纬度)')
return
}
const [lon, lat] = parts
const transformed = gcoord.transform(
[lon, lat],
gcoord[sourceCrs.value],
gcoord[targetCrs.value]
)
result.value = {
type: 'crs',
lon: transformed[0],
lat: transformed[1]
}
}
const doProjection = () => {
try {
if (projConfig.value.direction === 'toPlane') {
const { lng, lat, zoneType, withZone } = projConfig.value
if (!lng || !lat) return alert('请输入经纬度')
const res = projectToPlane(lng, lat, zoneType, withZone)
result.value = {
type: 'proj_plane',
easting: res.easting,
northing: res.northing,
zone: res.zone,
meridian: res.meridian
}
} else {
const { x, y, zoneType, withZone, manualZone } = projConfig.value
if (!x || !y) return alert('请输入平面坐标')
const res = unprojectToLngLat(x, y, zoneType, withZone, manualZone)
result.value = {
type: 'proj_lnglat',
lng: res.lng,
lat: res.lat,
zone: res.zone,
meridian: res.meridian
}
}
} catch (err) {
alert('转换失败: ' + err.message)
}
}
const copyResult = () => {
let text = ''
if (result.value.type === 'decimal') {
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
} else if (result.value.type === 'dms') {
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
} else if (result.value.type === 'crs') {
text = `${result.value.lon.toFixed(8)}, ${result.value.lat.toFixed(8)}`
} else if (result.value.type === 'proj_plane') {
text = `X (北移): ${result.value.northing.toFixed(4)}, Y (东移): ${result.value.easting.toFixed(4)}`
} else {
text = `${result.value.lng.toFixed(8)}, ${result.value.lat.toFixed(8)}`
}
navigator.clipboard.writeText(text).then(() => {
alert('已复制到剪贴板!')
})
}
// === Bulk Excel Conversion ===
const downloadTemplate = (type) => {
let wsData = []
let filename = ''
if (type === 'toPlane') {
wsData = [['点名', '经度', '纬度', '分带类型(3/6)', 'X包含带号(1/0)'], ['pt1', 116.397451, 39.909235, 3, 1]]
filename = '经纬度转平面模板.xlsx'
} else if (type === 'toLngLat') {
wsData = [['点名', 'X坐标(北移)', 'Y坐标(东移)', '分带类型(3/6)', '手动带号'], ['pt1', 4419519.82, 39533966.82, 3, '']]
filename = '平面转经纬度模板.xlsx'
} else if (type === 'toDecimal') {
wsData = [['点名', '纬度度', '纬度分', '纬度秒', '纬度方向(N/S)', '经度度', '经度分', '经度秒', '经度方向(E/W)'], ['pt1', 39, 54, 27, 'N', 116, 23, 50, 'E']]
filename = '度分秒转十进制模板.xlsx'
} else if (type === 'toDms') {
wsData = [['点名', '纬度(十进制)', '经度(十进制)'], ['pt1', 39.9075, 116.3972]]
filename = '十进制转度分秒模板.xlsx'
}
const ws = xlsx.utils.aoa_to_sheet(wsData)
const wb = xlsx.utils.book_new()
xlsx.utils.book_append_sheet(wb, ws, "Sheet1")
xlsx.writeFile(wb, filename)
}
const handleFileUpload = (e) => {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (evt) => {
try {
const data = evt.target.result
const wb = xlsx.read(data, { type: 'binary' })
const wsName = wb.SheetNames[0]
const ws = wb.Sheets[wsName]
const json = xlsx.utils.sheet_to_json(ws)
if (json.length === 0) return alert('Excel无数据')
// 识别是哪种模式 (通过列名)
const first = json[0]
const isToDecimal = '纬度度' in first
const isToDms = '纬度(十进制)' in first || '经度(十进制)' in first
const isToPlane = !isToDecimal && !isToDms && ('经度' in first || '经度(Lon)' in first)
const isToLngLat = 'X坐标(北移)' in first || 'X坐标' in first
const resultData = []
for (let i = 0; i < json.length; i++) {
const row = json[i]
const name = row['点名'] || `Point${i+1}`
const zoneType = parseInt(row['分带类型(3/6)'] || projConfig.value.zoneType)
if (isToDecimal) {
const latDeg = parseFloat(row['纬度度']) || 0
const latMin = parseFloat(row['纬度分']) || 0
const latSec = parseFloat(row['纬度秒']) || 0
const latDir = row['纬度方向(N/S)'] || 'N'
const lonDeg = parseFloat(row['经度度']) || 0
const lonMin = parseFloat(row['经度分']) || 0
const lonSec = parseFloat(row['经度秒']) || 0
const lonDir = row['经度方向(E/W)'] || 'E'
try {
let latDec = dmsToDecimal(latDeg, latMin, latSec)
if (latDir.toUpperCase() === 'S') latDec = -latDec
let lonDec = dmsToDecimal(lonDeg, lonMin, lonSec)
if (lonDir.toUpperCase() === 'W') lonDec = -lonDec
resultData.push({
...row,
'点名': name,
'纬度(十进制)': latDec.toFixed(8),
'经度(十进制)': lonDec.toFixed(8)
})
} catch (err) {
resultData.push({ ...row, '点名': name, '错误': err.message })
}
} else if (isToDms) {
const latDec = parseFloat(row['纬度(十进制)'])
const lonDec = parseFloat(row['经度(十进制)'])
if (!isNaN(latDec) && !isNaN(lonDec)) {
try {
resultData.push({
...row,
'点名': name,
'纬度(度分秒)': formatCoordinate(latDec, 'lat'),
'经度(度分秒)': formatCoordinate(lonDec, 'lon')
})
} catch (err) {
resultData.push({ ...row, '点名': name, '错误': err.message })
}
}
} else if (isToPlane) {
const lng = parseFloat(row['经度']) || parseFloat(row['经度(Lon)'])
const lat = parseFloat(row['纬度']) || parseFloat(row['纬度(Lat)'])
const withZone = (row['X包含带号(1/0)'] !== undefined) ? (parseInt(row['X包含带号(1/0)']) === 1) : true
if (lng && lat) {
try {
const res = projectToPlane(lng, lat, zoneType, withZone)
resultData.push({
...row,
'点名': name,
'经度': lng,
'纬度': lat,
'X坐标(北移/Northing)': res.northing.toFixed(4),
'Y坐标(东移/Easting)': res.easting.toFixed(4),
'带号': res.zone,
'中央子午线': res.meridian
})
} catch (err) {
resultData.push({ ...row, '点名': name, '错误': err.message })
}
}
} else if (isToLngLat) {
const northing = parseFloat(row['X坐标(北移)']) || parseFloat(row['X坐标'])
const easting = parseFloat(row['Y坐标(东移)']) || parseFloat(row['Y坐标'])
const manualZone = row['手动带号'] ? parseInt(row['手动带号']) : null
const withZone = easting > 1000000
if (northing && easting) {
try {
const res = unprojectToLngLat(easting, northing, zoneType, withZone, manualZone)
resultData.push({
...row,
'点名': name,
'X坐标(北)': northing,
'Y坐标(东)': easting,
'经度': res.lng.toFixed(8),
'纬度': res.lat.toFixed(8),
'带号': res.zone,
'中央子午线': res.meridian
})
} catch (err) {
resultData.push({ ...row, '点名': name, '错误': err.message })
}
}
}
}
const newWs = xlsx.utils.json_to_sheet(resultData)
const newWb = xlsx.utils.book_new()
xlsx.utils.book_append_sheet(newWb, newWs, "转换结果")
xlsx.writeFile(newWb, `坐标转换结果_${Date.now()}.xlsx`)
// clear input
e.target.value = ''
} catch (err) {
alert('文件解析失败: ' + err.message)
}
}
reader.readAsArrayBuffer(file)
}
</script>
<style scoped>
.container.pb-2xl {
padding-bottom: 4rem;
}
.tool-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
text-decoration: none;
margin-bottom: var(--spacing-md);
transition: color var(--transition-base);
}
.back-link:hover {
color: var(--primary);
}
/* --- Tabs Styling --- */
.tabs-wrapper {
margin-bottom: var(--spacing-xl);
}
.tabs-header {
display: flex;
overflow-x: auto;
border-bottom: 2px solid var(--border-color);
margin-bottom: var(--spacing-lg);
scrollbar-width: none; /* Firefox */
}
.tabs-header::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.tab-btn {
display: flex;
align-items: center;
padding: var(--spacing-md) var(--spacing-lg);
background: transparent;
border: none;
border-bottom: 3px solid transparent;
color: var(--text-secondary);
font-size: var(--font-size-md);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-base);
margin-bottom: -2px;
}
.tab-btn:hover {
color: var(--primary);
background: var(--bg-secondary);
}
.tab-btn.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.mr-xs {
margin-right: 4px;
}
.mb-lg {
margin-bottom: var(--spacing-lg);
}
.max-w-3xl {
max-width: 768px;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.text-center {
text-align: center;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.dms-input {
display: flex;
gap: var(--spacing-xs);
}
.dms-input .input {
width: 0;
flex: 1;
}
.px-xs {
padding-left: var(--spacing-xs);
padding-right: var(--spacing-xs);
}
.icon-sm { width: 16px; height: 16px; }
.icon-md { width: 20px; height: 20px; }
.icon-lg { width: 24px; height: 24px; vertical-align: bottom; }
.icon-xl { width: 48px; height: 48px; }
.checkbox-label {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
}
.result-box {
background: var(--bg-secondary);
padding: var(--spacing-md);
border-radius: var(--border-radius-sm);
border-left: 4px solid var(--primary);
}
.result-box p {
margin: var(--spacing-xs) 0;
font-family: monospace;
font-size: var(--font-size-md);
}
.btn-full { width: 100%; }
.text-success { color: #10b981; }
.upload-area {
margin-top: var(--spacing-lg);
}
.upload-label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
background: var(--bg-tertiary);
border: 2px dashed var(--border-color);
border-radius: var(--border-radius);
cursor: pointer;
transition: all var(--transition-base);
color: var(--text-secondary);
}
.upload-label:hover {
border-color: var(--primary);
background: var(--bg-secondary);
}
.hidden {
display: none;
}
</style>