Skip to content

实现 经纬度转换 工具

分类: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-inputel-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 效果截图

经纬度转换 效果截图

工具访问地址:https://www.i91tools.com/tools/coordinate