feat: 引入面积计算器和坐标转换器组件,并添加新的依赖项和实用函数。

This commit is contained in:
2026-03-18 23:01:58 +08:00
parent 8e21bd9562
commit 8bf55b6716
6 changed files with 892 additions and 123 deletions

132
package-lock.json generated
View File

@@ -11,8 +11,10 @@
"@turf/turf": "^7.3.3",
"gcoord": "^1.0.7",
"lucide-vue-next": "^0.563.0",
"proj4": "^2.20.4",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
@@ -3067,6 +3069,15 @@
"integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==",
"license": "MIT"
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/arc": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/arc/-/arc-0.2.0.tgz",
@@ -3084,6 +3095,28 @@
"node": "*"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -3102,6 +3135,18 @@
"tinyqueue": "^2.0.3"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3219,6 +3264,15 @@
}
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3318,6 +3372,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mgrs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
"integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -3415,6 +3475,19 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proj4": {
"version": "2.20.4",
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.4.tgz",
"integrity": "sha512-/EEBoXjBw+zW1Lofinw0YFQ4OFOqC6XThnwRAgjEHw8WBN+wSy/6aeqRRdjy4b2igy3X0k3RNDuwcYqcQq7nDw==",
"license": "MIT",
"dependencies": {
"mgrs": "1.0.0",
"wkt-parser": "^1.5.3"
},
"funding": {
"url": "https://github.com/sponsors/ahocevar"
}
},
"node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
@@ -3502,6 +3575,18 @@
"integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==",
"license": "BDS-3-Clause"
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/sweepline-intersections": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz",
@@ -3678,6 +3763,51 @@
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/wkt-parser": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.4.tgz",
"integrity": "sha512-heRp3QBynj8SAGepAkE8h2k4KhUGRqzgwlSRgqNhxjmSIeSvE5ZrV8n1uy5jk+iJO2jmfffIwjdAaTirBOOx0A==",
"license": "MIT"
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
}
}
}

View File

@@ -12,8 +12,10 @@
"@turf/turf": "^7.3.3",
"gcoord": "^1.0.7",
"lucide-vue-next": "^0.563.0",
"proj4": "^2.20.4",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",

View File

@@ -13,16 +13,45 @@
<div class="grid grid-2 gap-xl">
<!-- Points Input -->
<div class="card">
<h3 class="card-title flex justify-between">
<span><LayoutList class="icon-md" /> 顶点坐标 (经度, 纬度)</span>
<button @click="addPoint" class="btn btn-secondary btn-sm"> 添加顶点</button>
</h3>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 class="card-title" style="margin: 0; display: flex; align-items: center; gap: 8px;">
<LayoutList class="icon-md" /> 顶点坐标 (经度, 纬度)
</h3>
<button @click="addPoint" class="btn btn-secondary btn-sm" style="display: flex; align-items: center; gap: 4px; padding: 4px 12px; font-size: 0.875rem;">
添加顶点
</button>
</div>
<div class="mt-lg mb-md grid grid-2 gap-sm">
<div class="input-group">
<label class="input-label">坐标类型</label>
<select v-model="coordType" class="input" @change="handleModeChange">
<option value="lnglat">经纬度 (Lng/Lat)</option>
<option value="planar">平面坐标 (X/Y)</option>
</select>
</div>
<div class="input-group">
<label class="input-label">计算方式</label>
<select v-model="calcMethod" class="input" :disabled="coordType === 'planar'">
<option value="geodetic">测地线 (严密球面)</option>
<option value="planar">简单平面 (直角公式)</option>
</select>
</div>
</div>
<div class="points-list">
<div v-for="(point, index) in points" :key="index" class="point-input">
<span class="point-index">{{ index + 1 }}</span>
<input type="number" v-model.number="point.lng" class="input" placeholder="经度 (Lon)" step="0.000001">
<input type="number" v-model.number="point.lat" class="input" placeholder="纬度 (Lat)" step="0.000001">
<template v-if="coordType === 'lnglat'">
<input type="number" v-model.number="point.lng" class="input" placeholder="经度 (Lon)" step="0.000001">
<input type="number" v-model.number="point.lat" class="input" placeholder="纬度 (Lat)" step="0.000001">
</template>
<template v-else>
<input type="number" v-model.number="point.x" class="input" placeholder="X坐标 (北移)">
<input type="number" v-model.number="point.y" class="input" placeholder="Y坐标 (东移)">
</template>
<button v-if="points.length > 3" @click="removePoint(index)" class="btn-remove" title="删除">
<Trash2 class="icon-sm" />
</button>
@@ -30,13 +59,6 @@
</div>
<div class="mt-lg">
<div class="input-group">
<label class="input-label">计算模式</label>
<select v-model="calcMode" class="input">
<option value="geodetic">球面计算 (WGS84 测地线)</option>
<option value="planar">平面计算 (简单几何)</option>
</select>
</div>
<button @click="calculate" class="btn btn-primary btn-full mt-md">开始计算</button>
</div>
</div>
@@ -87,44 +109,109 @@ import * as turf from '@turf/turf'
import { ChevronLeft, SquareStack, LayoutList, Trash2, Activity, Info } from 'lucide-vue-next'
const points = ref([
{ lng: 116.391, lat: 39.907 },
{ lng: 116.401, lat: 39.907 },
{ lng: 116.401, lat: 39.917 },
{ lng: 116.391, lat: 39.917 }
{ lng: 108.5597255, lat: 22.8589543, x: 36557444.57, y: 2529026.75 },
{ lng: 108.5607409, lat: 22.8589507, x: 36557548.78, y: 2529026.75 },
{ lng: 108.5607373, lat: 22.8580860, x: 36557548.78, y: 2528930.99 },
{ lng: 108.5597220, lat: 22.8580896, x: 36557444.57, y: 2528930.99 }
])
const calcMode = ref('geodetic')
const coordType = ref('lnglat')
const calcMethod = ref('geodetic')
const area = ref(null)
const perimeter = ref(0)
const handleModeChange = () => {
area.value = null
perimeter.value = 0
if (coordType.value === 'planar') {
calcMethod.value = 'planar'
} else {
calcMethod.value = 'geodetic'
}
}
const addPoint = () => {
const last = points.value[points.value.length - 1]
points.value.push({ lng: last.lng + 0.01, lat: last.lat })
points.value.push({
lng: last.lng !== undefined ? Number(last.lng) + 0.001 : 116.391,
lat: last.lat !== undefined ? Number(last.lat) : 39.907,
x: last.x !== undefined ? Number(last.x) + 100 : 4419500,
y: last.y !== undefined ? Number(last.y) : 39533900
})
}
const removePoint = (index) => {
points.value.splice(index, 1)
}
const calculate = () => {
// Turf needs the first and last point to be the same to close the polygon
const coords = points.value.map(p => [p.lng, p.lat])
coords.push(coords[0])
try {
const polygon = turf.polygon([coords])
const calculatePlanar = (pts) => {
let a = 0;
let p = 0;
const n = pts.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
// Shoelace: sum(x_i * y_{i+1} - x_{i+1} * y_i)
a += pts[i].x * pts[j].y;
a -= pts[j].x * pts[i].y;
if (calcMode.value === 'geodetic') {
// Perimeter
const dx = pts[j].x - pts[i].x;
const dy = pts[j].y - pts[i].y;
p += Math.sqrt(dx*dx + dy*dy);
}
return { area: Math.abs(a / 2), perimeter: p };
}
const calculate = () => {
try {
// Ensure all inputs are numbers
const rawPoints = points.value.map(p => ({
lng: Number(p.lng || 0),
lat: Number(p.lat || 0),
x: Number(p.x || 0),
y: Number(p.y || 0)
}));
if (coordType.value === 'lnglat' && calcMethod.value === 'geodetic') {
const coords = rawPoints.map(p => [p.lng, p.lat])
coords.push(coords[0])
const polygon = turf.polygon([coords])
area.value = turf.area(polygon)
perimeter.value = turf.length(polygon, { units: 'meters' })
} else {
// Simple planar area as fallback/comparison
area.value = Math.abs(turf.area(polygon)) // Turf area is always geodetic, for true planar you'd need a custom formula
// Note: Turf.js doesn't have a dedicated "planar" area for user-defined units,
// but we can simulate it or just use geodetic as it's more accurate for surveying.
let pts = [];
if (coordType.value === 'planar') {
// *** 关键改进:使用相对坐标偏移,防止大数计算丢失精度 ***
const x0 = rawPoints[0].x;
const y0 = rawPoints[0].y;
pts = rawPoints.map(p => ({ x: p.x - x0, y: p.y - y0 }));
} else {
// Approximate LngLat to meters using Equirectangular projection at local latitude
const latAvg = rawPoints.reduce((acc, cur) => acc + cur.lat, 0) / rawPoints.length;
const radLat = latAvg * Math.PI / 180;
const kx = 111319.49 * Math.cos(radLat);
const ky = 111319.49;
// Use relative coordinates to avoid large number precision issues with degree calculation
const lng0 = rawPoints[0].lng;
const lat0 = rawPoints[0].lat;
pts = rawPoints.map(p => ({
x: (p.lng - lng0) * kx,
y: (p.lat - lat0) * ky
}));
}
if (pts.length < 3) {
alert('顶点数量不足')
return
}
const res = calculatePlanar(pts)
area.value = res.area
perimeter.value = res.perimeter
}
} catch (e) {
alert('多边形无效,请检查坐标点顺序(需按顺时针或逆时针排列且不能自相交)')
alert('计算失败: ' + e.message)
}
}

View File

@@ -1,94 +1,215 @@
<template>
<div class="container">
<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" /> 坐标转换工具
<Globe class="icon-lg" /> 坐标系统与格式转换
</h2>
<p>支持经纬度格式转换常用地图坐标系互转 (WGS84, GCJ02, BD09)</p>
<p>支持经纬度格式转换常用地图坐标系互转 CGCS2000 高斯平面投影与批量转换</p>
</div>
<div class="grid grid-2 gap-xl">
<!-- Format Conversion -->
<div class="card">
<h3 class="card-title">
<RefreshCw class="icon-md" /> 格式转换 (DMS Decimal)
</h3>
<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>
<div class="grid grid-2 gap-md">
<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>
<div class="dms-input">
<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">
<option value="N">N</option>
<option value="S">S</option>
<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="convertToDecimal" class="btn btn-primary btn-full mt-md">转换为十进制</button>
</div>
<div>
<div class="input-group">
<label class="input-label">十进制 度分秒</label>
<div class="flex gap-xs">
<input type="number" v-model.number="decimal.lat" class="input" placeholder="纬度">
<input type="number" v-model.number="decimal.lon" class="input" placeholder="经度">
<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>
<button @click="convertToDms" class="btn btn-primary btn-full mt-md">转换为度分秒</button>
</div>
</div>
</div>
<!-- CRS Transformation -->
<div class="card">
<h3 class="card-title">
<MapPin class="icon-md" /> 坐标系转换 (CRS)
</h3>
<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">
<div class="input-group">
<label class="input-label">来源坐标系</label>
<select v-model="sourceCrs" class="input">
<option value="WGS84">WGS84 (大地坐标)</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 (大地坐标)</option>
<option value="BD09">BD09 (百度坐标)</option>
</select>
<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>
<button @click="transformCrs" class="btn btn-primary btn-full mt-md">执行转换</button>
</div>
</div>
<!-- Results Area -->
<div v-if="result" class="card mt-xl">
<!-- 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" /> 转换结果
<CheckCircle2 class="icon-md text-success" /> 单点计算结果
</h4>
<button @click="copyResult" class="btn btn-secondary btn-sm">
<Copy class="icon-sm" /> 复制结果
<button @click="copyResult" class="btn btn-secondary btn-sm flex-center gap-xs">
<Copy class="icon-sm" /> 复制
</button>
</div>
@@ -102,10 +223,20 @@
<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>
<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>
@@ -114,31 +245,56 @@
<script setup>
import { ref } from 'vue'
import gcoord from 'gcoord'
import { ChevronLeft, Globe, RefreshCw, MapPin, Copy, CheckCircle2 } from 'lucide-vue-next'
import { dmsToDecimal, decimalToDms, formatCoordinate } from '../utils/coordinate'
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'
// DMS Conversion State
// === 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: 39, minutes: 54, seconds: 27, direction: 'N' },
lon: { degrees: 116, minutes: 23, seconds: 50, direction: 'E' }
lat: { degrees: 22, minutes: 49, seconds: 16.8640, direction: 'N' },
lon: { degrees: 108, minutes: 18, seconds: 37.4184, direction: 'E' }
})
const decimal = ref({ lat: 39.9075, lon: 116.3972 })
const decimal = ref({ lat: 22.8213511, lon: 108.3103940 })
// CRS Transformation State
// === 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: 116.397451,
lat: 39.909235,
x: 39533966.82, // Easting (Y)
y: 4419519.82, // Northing (X)
manualZone: 39
})
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(6),
lon: (dms.value.lon.direction === 'W' ? -lonDecimal : lonDecimal).toFixed(6)
lat: (dms.value.lat.direction === 'S' ? -latDecimal : latDecimal).toFixed(8),
lon: (dms.value.lon.direction === 'W' ? -lonDecimal : lonDecimal).toFixed(8)
}
}
@@ -171,23 +327,220 @@ const transformCrs = () => {
}
}
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 {
} 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.readAsBinaryString(file)
}
</script>
<style scoped>
.container.pb-2xl {
padding-bottom: 4rem;
}
.tool-header {
text-align: center;
margin-bottom: var(--spacing-xl);
@@ -207,15 +560,108 @@ const copyResult = () => {
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: grid;
grid-template-columns: 1fr 1fr 1fr 0.8fr;
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);
@@ -231,12 +677,33 @@ const copyResult = () => {
}
.btn-full { width: 100%; }
.text-success { color: #10b981; }
@media (max-width: 768px) {
.dms-input {
grid-template-columns: 1fr 1fr;
}
.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>

View File

@@ -24,7 +24,7 @@ export function decimalToDms(decimal) {
const minutesDecimal = (absDecimal - degrees) * 60;
const minutes = Math.floor(minutesDecimal);
const seconds = (minutesDecimal - minutes) * 60;
return {
degrees: decimal < 0 ? -degrees : degrees,
minutes,
@@ -48,8 +48,8 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
const Δλ = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
@@ -75,9 +75,9 @@ export function planarDistance(x1, y1, x2, y2) {
*/
export function formatCoordinate(decimal, type) {
const dms = decimalToDms(decimal);
const direction = type === 'lat'
const direction = type === 'lat'
? (decimal >= 0 ? 'N' : 'S')
: (decimal >= 0 ? 'E' : 'W');
return `${Math.abs(dms.degrees)}° ${dms.minutes}' ${dms.seconds.toFixed(2)}" ${direction}`;
return `${Math.abs(dms.degrees)}°${dms.minutes}'${dms.seconds.toFixed(4)}" ${direction}`;
}

83
src/utils/proj.js Normal file
View File

@@ -0,0 +1,83 @@
import proj4 from 'proj4';
/**
* 自动计算中央子午线及带号
* @param {number} lng - 经度
* @param {number} zoneType - 分带类型3 或 6
* @returns {{zone: number, meridian: number}}
*/
export function getCentralMeridian(lng, zoneType) {
if (zoneType === 3) {
// 3度带中央子午线 = 带号 * 3带号 = round(lng / 3)
const zone = Math.round(lng / 3);
return { zone, meridian: zone * 3 };
} else {
// 6度带带号 = int((lng + 6) / 6),或是 int(lng / 6) + 1中央子午线 = 带号 * 6 - 3
const zone = Math.floor(lng / 6) + 1;
return { zone, meridian: zone * 6 - 3 };
}
}
/**
* 获取 CGCS2000 高斯克吕格投影的 Proj4 定义字符串
* CGCS2000 的椭球参数与 GRS80 基本一致,可以通用
* @param {number} meridian - 中央子午线
* @param {boolean} withZone - 坐标 X 中是否包含带号
* @param {number} zoneNumber - 带号
* @returns {string} Proj4 string
*/
export function getProjString(meridian, withZone, zoneNumber) {
const falseEasting = withZone ? 500000 + zoneNumber * 1000000 : 500000;
return `+proj=tmerc +lat_0=0 +lon_0=${meridian} +k=1 +x_0=${falseEasting} +y_0=0 +ellps=GRS80 +units=m +no_defs`;
}
/**
* 经纬度转平面坐标 (CGCS2000)
* @param {number} lng
* @param {number} lat
* @param {number} zoneType - 3 或 6
* @param {boolean} withZone - 是否加带号
* @returns {{x: number, y: number, zone: number, meridian: number}} 平面坐标 (x: 东移/北移)
* 注:国内测量学中通常 X 指向北Y 指向东。但在 GIS (Proj4) 中X 对应经度/东移(Easting)Y 对应纬度/北移(Northing)
* 我们返回对象的 x 为 Easting (常规Y)y 为 Northing (常规X)
*/
export function projectToPlane(lng, lat, zoneType, withZone) {
const { zone, meridian } = getCentralMeridian(lng, zoneType);
const projStr = getProjString(meridian, withZone, zone);
const [x, y] = proj4('WGS84', projStr, [lng, lat]);
return { easting: x, northing: y, zone, meridian };
}
/**
* 平面坐标转经纬度 (CGCS2000)
* @param {number} easting (对应国内测量的 Y 坐标)
* @param {number} northing (对应国内测量的 X 坐标)
* @param {number} zoneType - 3 或 6
* @param {boolean} withZone - 坐标中是否包含带号
* @param {number} specifiedZone - 手动指定的带号 (如果不加带号,则必须提供)
* @returns {{lng: number, lat: number, zone: number, meridian: number}}
*/
export function unprojectToLngLat(easting, northing, zoneType, withZone, specifiedZone) {
let zone = specifiedZone;
if (withZone) {
if (!zone) {
// 从 easting 中提取带号 (例如 39500000 -> 39)
// Easting 一般在带内是 500km 左右,加上前缀的话,长度为 8 位
const eastStr = Math.floor(easting).toString();
if (eastStr.length === 8) {
zone = parseInt(eastStr.substring(0, 2), 10);
} else if (eastStr.length === 7) {
zone = parseInt(eastStr.substring(0, 1), 10);
}
}
}
if (!zone) {
throw new Error('无法确定带号,请手动指定中央子午线对应的带号');
}
const meridian = zoneType === 3 ? zone * 3 : zone * 6 - 3;
const projStr = getProjString(meridian, withZone, zone);
const [lng, lat] = proj4(projStr, 'WGS84', [easting, northing]);
return { lng, lat, zone, meridian };
}