feat: 引入面积计算器和坐标转换器组件,并添加新的依赖项和实用函数。
This commit is contained in:
132
package-lock.json
generated
132
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
<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 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;
|
||||
|
||||
// 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 = () => {
|
||||
// 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])
|
||||
// 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 (calcMode.value === 'geodetic') {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,67 @@
|
||||
<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>
|
||||
<div class="input-group">
|
||||
<!-- 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">
|
||||
<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">
|
||||
<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>
|
||||
<div class="input-group">
|
||||
<div class="flex-col">
|
||||
<div class="input-group flex-1">
|
||||
<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="经度">
|
||||
<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>
|
||||
@@ -47,48 +69,147 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CRS Transformation -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<MapPin class="icon-md" /> 坐标系转换 (CRS)
|
||||
</h3>
|
||||
|
||||
<!-- 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">
|
||||
<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 (大地坐标)</option>
|
||||
<option value="GCJ02">GCJ02 (火星坐标)</option>
|
||||
<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 (大地坐标)</option>
|
||||
<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-md">执行转换</button>
|
||||
<button @click="transformCrs" class="btn btn-primary btn-full mt-lg">执行地图坐标转换</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Area -->
|
||||
<div v-if="result" class="card mt-xl">
|
||||
<!-- 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" /> 转换结果
|
||||
<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>
|
||||
|
||||
@@ -79,5 +79,5 @@ export function formatCoordinate(decimal, type) {
|
||||
? (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
83
src/utils/proj.js
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user