Appearance
实现 经纬度转换 工具
分类:general | 标签:经纬度、坐标、GPS 十进制与度分秒互转
2.1 功能说明
十进制与度分秒互转
2.1.1 使用指南
功能说明
十进制度(如 39.9042)与度分秒(如 39°54′15″N)互转,支持经纬度同时处理。
使用场景
地图标点、GPS 数据导入导出。
坐标系互转
「坐标系互转」页签支持 WGS-84、GCJ-02、BD-09 三种常用坐标系两两互转:
- WGS-84:GPS 原始坐标,国际通用大地坐标系,卫星定位设备与多数境外地图使用。
- GCJ-02:国测局坐标(俗称火星坐标),在 WGS-84 基础上做非线性偏移,国内多数地图服务使用。
- BD-09:百度坐标,在 GCJ-02 基础上再做一次加密偏移。
转换链路:WGS-84 ↔ GCJ-02 使用公开的 transformLat / transformLng 偏移函数与 outOfChina 判断(境外坐标不做偏移,原值返回);GCJ-02 ↔ BD-09 使用公开的极坐标偏移公式(常数 0.0065 / 0.006 / X_PI)。WGS-84 与 BD-09 之间通过 GCJ-02 中转。
GCJ-02 反算 WGS-84 无解析解,本工具采用迭代逼近(最多 10 次,收敛阈值 1e-9 度,约 0.1 毫米),精度高于常见的一次差值近似法。
使用方式:单点换算直接输入「纬度,经度」;批量换算每行一条「纬度,经度」,支持空格或逗号分隔,可复制结果或下载 CSV。本工具仅做坐标数值换算,不加载任何地图底图。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
toDms()toOne()toDd()parseOne()clear()outOfChina()transformLat()transformLng()wgs84ToGcj02()gcj02ToWgs84()gcj02ToBd09()bd09ToGcj02()convertPoint()haversine()swapSys()parsePair()fmt()convertSingle()sysLabel()convertBatch()downloadCsv()clearBatch()copyText()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, computed } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'
const tab = ref('dms')
const dd = ref('')
const dms = ref('')
const error = ref('')
const result = ref('')
function toDms() {
error.value = result.value = ''
const parts = dd.value.split(',').map(s => s.trim())
if (parts.length !== 2 || !parts[0] || !parts[1]) { error.value = '请输入 纬度,经度 两个值'; return }
const out = parts.map(toOne).map((s, i) => s + (i === 0 ? (parts[0].startsWith('-') ? 'S' : 'N') : (parts[1].startsWith('-') ? 'W' : 'E')))
dms.value = out.join(',')
result.value = '度分秒:' + dms.value
}
function toOne(v) {
const n = Number(v)
if (isNaN(n) || n < -180 || n > 180) throw new Error('数值超范围')
const a = Math.abs(n)
const d = Math.floor(a)
const mFloat = (a - d) * 60
const m = Math.floor(mFloat)
const s = ((mFloat - m) * 60).toFixed(2)
return d + '°' + m + '′' + s + '″'
}
function toDd() {
error.value = result.value = ''
const parts = dms.value.split(',').map(s => s.trim())
if (parts.length !== 2 || !parts[0] || !parts[1]) { error.value = '请输入 纬度,经度 两个值'; return }
try {
const la = parseOne(parts[0]) * (parts[0].slice(-1).toUpperCase() === 'S' ? -1 : 1)
const lo = parseOne(parts[1]) * (parts[1].slice(-1).toUpperCase() === 'W' ? -1 : 1)
dd.value = la.toFixed(6) + ',' + lo.toFixed(6)
result.value = '十进制:' + dd.value
} catch (e) { error.value = e.message }
}
function parseOne(str) {
const m = str.match(/^(-?\d+)°(\d+)′([\d.]+)″[NSEW]$/i)
if (!m) throw new Error('度分秒格式不正确:' + str)
return Number(m[1]) + Number(m[2]) / 60 + Number(m[3]) / 3600
}
function clear() { dd.value = dms.value = error.value = result.value = '' }
/* ---------------- 坐标系互转(WGS-84 / GCJ-02 / BD-09) ---------------- */
const SYSTEMS = [
{ key: 'wgs84', label: 'WGS-84(GPS 原始)' },
{ key: 'gcj02', label: 'GCJ-02(火星坐标)' },
{ key: 'bd09', label: 'BD-09(百度坐标)' }
]
const PI = Math.PI
const X_PI = PI * 3000 / 180
const A = 6378245.0
const EE = 0.00669342162296594323
function outOfChina(lng, lat) {
return !(lng > 73.66 && lng < 135.05 && lat > 3.86 && lat < 53.55)
}
function transformLat(x, y) {
let ret = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x))
ret += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3
ret += (20 * Math.sin(y * PI) + 40 * Math.sin(y / 3 * PI)) * 2 / 3
ret += (160 * Math.sin(y / 12 * PI) + 320 * Math.sin(y * PI / 30)) * 2 / 3
return ret
}
function transformLng(x, y) {
let ret = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x))
ret += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3
ret += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3
ret += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3
return ret
}
function wgs84ToGcj02(lng, lat) {
if (outOfChina(lng, lat)) return [lng, lat]
let dLat = transformLat(lng - 105, lat - 35)
let dLng = transformLng(lng - 105, lat - 35)
const radLat = lat / 180 * PI
let magic = Math.sin(radLat)
magic = 1 - EE * magic * magic
const sqrtMagic = Math.sqrt(magic)
dLat = (dLat * 180) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI)
dLng = (dLng * 180) / (A / sqrtMagic * Math.cos(radLat) * PI)
return [lng + dLng, lat + dLat]
}
// GCJ-02 反算无解析解,采用迭代逼近
function gcj02ToWgs84(lng, lat) {
if (outOfChina(lng, lat)) return [lng, lat]
let wLng = lng
let wLat = lat
for (let i = 0; i < 10; i++) {
const [tLng, tLat] = wgs84ToGcj02(wLng, wLat)
const dLng = lng - tLng
const dLat = lat - tLat
wLng += dLng
wLat += dLat
if (Math.abs(dLng) < 1e-9 && Math.abs(dLat) < 1e-9) break
}
return [wLng, wLat]
}
function gcj02ToBd09(lng, lat) {
const z = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * X_PI)
const theta = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * X_PI)
return [z * Math.cos(theta) + 0.0065, z * Math.sin(theta) + 0.006]
}
function bd09ToGcj02(lng, lat) {
const x = lng - 0.0065
const y = lat - 0.006
const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI)
const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI)
return [z * Math.cos(theta), z * Math.sin(theta)]
}
function convertPoint(lng, lat, src, dst) {
if (src === dst) return [lng, lat]
// 统一先折算到 GCJ-02 中间态,再折算到目标坐标系
let g
if (src === 'wgs84') g = wgs84ToGcj02(lng, lat)
else if (src === 'bd09') g = bd09ToGcj02(lng, lat)
else g = [lng, lat]
if (dst === 'gcj02') return g
if (dst === 'bd09') return gcj02ToBd09(g[0], g[1])
return gcj02ToWgs84(g[0], g[1])
}
function haversine(lng1, lat1, lng2, lat2) {
const R = 6371008.8
const rad = d => d * PI / 180
const dLat = rad(lat2 - lat1)
const dLng = rad(lng2 - lng1)
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
const fromSys = ref('wgs84')
const toSys = ref('gcj02')
const singleInput = ref('')
const singleOutput = ref('')
const singleDetail = ref(null)
const sysError = ref('')
function swapSys() {
const t = fromSys.value
fromSys.value = toSys.value
toSys.value = t
}
function parsePair(line) {
const parts = line.split(/[,,\s]+/).map(s => s.trim()).filter(Boolean)
if (parts.length !== 2) return null
const lat = Number(parts[0])
const lng = Number(parts[1])
if (!isFinite(lat) || !isFinite(lng)) return null
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null
return { lat, lng }
}
function fmt(n) { return n.toFixed(6) }
function convertSingle() {
sysError.value = ''
singleOutput.value = ''
singleDetail.value = null
const p = parsePair(singleInput.value)
if (!p) { sysError.value = '请输入合法的「纬度,经度」,纬度 -90~90,经度 -180~180'; return }
const [lng, lat] = convertPoint(p.lng, p.lat, fromSys.value, toSys.value)
singleOutput.value = fmt(lat) + ',' + fmt(lng)
singleDetail.value = {
src: fmt(p.lat) + ',' + fmt(p.lng) + '(' + sysLabel(fromSys.value) + ')',
dst: singleOutput.value + '(' + sysLabel(toSys.value) + ')',
distance: haversine(p.lng, p.lat, lng, lat).toFixed(2),
inChina: !outOfChina(p.lng, p.lat)
}
}
function sysLabel(key) {
const s = SYSTEMS.find(x => x.key === key)
return s ? s.label.split('(')[0] : key
}
const batchInput = ref('')
const batchRows = ref([])
const batchError = ref('')
function convertBatch() {
batchError.value = ''
batchRows.value = []
const lines = batchInput.value.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
if (!lines.length) { batchError.value = '请先粘贴需要换算的坐标,每行一条'; return }
if (lines.length > 2000) { batchError.value = '单次最多处理 2000 行,请分批处理'; return }
const rows = []
const bad = []
lines.forEach((line, i) => {
const p = parsePair(line)
if (!p) { bad.push(i + 1); return }
const [lng, lat] = convertPoint(p.lng, p.lat, fromSys.value, toSys.value)
rows.push({
index: rows.length + 1,
srcLat: fmt(p.lat),
srcLng: fmt(p.lng),
dstLat: fmt(lat),
dstLng: fmt(lng),
distance: haversine(p.lng, p.lat, lng, lat).toFixed(2)
})
})
batchRows.value = rows
if (bad.length) batchError.value = '已跳过 ' + bad.length + ' 行无法解析的数据(行号:' + bad.slice(0, 20).join('、') + (bad.length > 20 ? ' 等' : '') + ')'
}
const batchCsv = computed(() => {
const head = '序号,源纬度,源经度,目标纬度,目标经度,偏移米'
return [head].concat(batchRows.value.map(r => [r.index, r.srcLat, r.srcLng, r.dstLat, r.dstLng, r.distance].join(','))).join('\n')
})
function downloadCsv() {
if (!batchRows.value.length) return
const blob = new Blob(['\ufeff' + batchCsv.value], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fromSys.value + '-to-' + toSys.value + '.csv'
a.click()
URL.revokeObjectURL(url)
}
function clearBatch() {
batchInput.value = ''
batchRows.value = []
batchError.value = ''
}
function copyText(text) {
if (text) navigator.clipboard?.writeText(text)
}2.3 效果截图
