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",
|
"@turf/turf": "^7.3.3",
|
||||||
"gcoord": "^1.0.7",
|
"gcoord": "^1.0.7",
|
||||||
"lucide-vue-next": "^0.563.0",
|
"lucide-vue-next": "^0.563.0",
|
||||||
|
"proj4": "^2.20.4",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
@@ -3067,6 +3069,15 @@
|
|||||||
"integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==",
|
"integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/arc": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/arc/-/arc-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/arc/-/arc-0.2.0.tgz",
|
||||||
@@ -3084,6 +3095,28 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/commander": {
|
||||||
"version": "2.20.3",
|
"version": "2.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
@@ -3102,6 +3135,18 @@
|
|||||||
"tinyqueue": "^2.0.3"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -3318,6 +3372,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -3415,6 +3475,19 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/quickselect": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||||
@@ -3502,6 +3575,18 @@
|
|||||||
"integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==",
|
"integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==",
|
||||||
"license": "BDS-3-Clause"
|
"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": {
|
"node_modules/sweepline-intersections": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz",
|
||||||
@@ -3678,6 +3763,51 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.5.0"
|
"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",
|
"@turf/turf": "^7.3.3",
|
||||||
"gcoord": "^1.0.7",
|
"gcoord": "^1.0.7",
|
||||||
"lucide-vue-next": "^0.563.0",
|
"lucide-vue-next": "^0.563.0",
|
||||||
|
"proj4": "^2.20.4",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
|||||||
@@ -13,16 +13,45 @@
|
|||||||
<div class="grid grid-2 gap-xl">
|
<div class="grid grid-2 gap-xl">
|
||||||
<!-- Points Input -->
|
<!-- Points Input -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 class="card-title flex justify-between">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
<span><LayoutList class="icon-md" /> 顶点坐标 (经度, 纬度)</span>
|
<h3 class="card-title" style="margin: 0; display: flex; align-items: center; gap: 8px;">
|
||||||
<button @click="addPoint" class="btn btn-secondary btn-sm">➕ 添加顶点</button>
|
<LayoutList class="icon-md" /> 顶点坐标 (经度, 纬度)
|
||||||
</h3>
|
</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 class="points-list">
|
||||||
<div v-for="(point, index) in points" :key="index" class="point-input">
|
<div v-for="(point, index) in points" :key="index" class="point-input">
|
||||||
<span class="point-index">{{ index + 1 }}</span>
|
<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="删除">
|
<button v-if="points.length > 3" @click="removePoint(index)" class="btn-remove" title="删除">
|
||||||
<Trash2 class="icon-sm" />
|
<Trash2 class="icon-sm" />
|
||||||
</button>
|
</button>
|
||||||
@@ -30,13 +59,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-lg">
|
<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>
|
<button @click="calculate" class="btn btn-primary btn-full mt-md">开始计算</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,44 +109,109 @@ import * as turf from '@turf/turf'
|
|||||||
import { ChevronLeft, SquareStack, LayoutList, Trash2, Activity, Info } from 'lucide-vue-next'
|
import { ChevronLeft, SquareStack, LayoutList, Trash2, Activity, Info } from 'lucide-vue-next'
|
||||||
|
|
||||||
const points = ref([
|
const points = ref([
|
||||||
{ lng: 116.391, lat: 39.907 },
|
{ lng: 108.5597255, lat: 22.8589543, x: 36557444.57, y: 2529026.75 },
|
||||||
{ lng: 116.401, lat: 39.907 },
|
{ lng: 108.5607409, lat: 22.8589507, x: 36557548.78, y: 2529026.75 },
|
||||||
{ lng: 116.401, lat: 39.917 },
|
{ lng: 108.5607373, lat: 22.8580860, x: 36557548.78, y: 2528930.99 },
|
||||||
{ lng: 116.391, lat: 39.917 }
|
{ 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 area = ref(null)
|
||||||
const perimeter = ref(0)
|
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 addPoint = () => {
|
||||||
const last = points.value[points.value.length - 1]
|
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) => {
|
const removePoint = (index) => {
|
||||||
points.value.splice(index, 1)
|
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 = () => {
|
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 {
|
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)
|
area.value = turf.area(polygon)
|
||||||
perimeter.value = turf.length(polygon, { units: 'meters' })
|
perimeter.value = turf.length(polygon, { units: 'meters' })
|
||||||
} else {
|
} else {
|
||||||
// Simple planar area as fallback/comparison
|
let pts = [];
|
||||||
area.value = Math.abs(turf.area(polygon)) // Turf area is always geodetic, for true planar you'd need a custom formula
|
if (coordType.value === 'planar') {
|
||||||
// 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.
|
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) {
|
} catch (e) {
|
||||||
alert('多边形无效,请检查坐标点顺序(需按顺时针或逆时针排列且不能自相交)')
|
alert('计算失败: ' + e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +1,215 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container pb-2xl">
|
||||||
<div class="tool-header">
|
<div class="tool-header">
|
||||||
<router-link to="/" class="back-link">
|
<router-link to="/" class="back-link">
|
||||||
<ChevronLeft class="icon-sm" /> 返回首页
|
<ChevronLeft class="icon-sm" /> 返回首页
|
||||||
</router-link>
|
</router-link>
|
||||||
<h2>
|
<h2>
|
||||||
<Globe class="icon-lg" /> 坐标转换工具
|
<Globe class="icon-lg" /> 坐标系统与格式转换
|
||||||
</h2>
|
</h2>
|
||||||
<p>支持经纬度格式转换及常用地图坐标系互转 (WGS84, GCJ02, BD09)</p>
|
<p>支持经纬度格式转换、常用地图坐标系互转及 CGCS2000 高斯平面投影与批量转换</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-2 gap-xl">
|
<div class="tabs-wrapper">
|
||||||
<!-- Format Conversion -->
|
<!-- Tab Navigation -->
|
||||||
<div class="card">
|
<div class="tabs-header">
|
||||||
<h3 class="card-title">
|
<button
|
||||||
<RefreshCw class="icon-md" /> 格式转换 (DMS ↔ Decimal)
|
v-for="tab in tabsList"
|
||||||
</h3>
|
: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">
|
<!-- Tab Content -->
|
||||||
<div>
|
<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">
|
<div class="input-group">
|
||||||
<label class="input-label">度分秒 → 十进制</label>
|
<label class="input-label">输入坐标 (经度, 纬度)</label>
|
||||||
<div class="dms-input">
|
<input type="text" v-model="crsInput" class="input" placeholder="例: 116.397, 39.908">
|
||||||
<input type="number" v-model.number="dms.lat.degrees" class="input" placeholder="度">
|
</div>
|
||||||
<input type="number" v-model.number="dms.lat.minutes" class="input" placeholder="分">
|
|
||||||
<input type="number" v-model.number="dms.lat.seconds" class="input" placeholder="秒">
|
<div class="grid grid-2 gap-md mt-md">
|
||||||
<select v-model="dms.lat.direction" class="input">
|
<div class="input-group">
|
||||||
<option value="N">N</option>
|
<label class="input-label">来源坐标系</label>
|
||||||
<option value="S">S</option>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="convertToDecimal" class="btn btn-primary btn-full mt-md">转换为十进制</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<button @click="transformCrs" class="btn btn-primary btn-full mt-lg">执行地图坐标转换</button>
|
||||||
<div class="input-group">
|
</div>
|
||||||
<label class="input-label">十进制 → 度分秒</label>
|
</div>
|
||||||
<div class="flex gap-xs">
|
|
||||||
<input type="number" v-model.number="decimal.lat" class="input" placeholder="纬度">
|
<!-- 3. CGCS2000 Projection -->
|
||||||
<input type="number" v-model.number="decimal.lon" class="input" placeholder="经度">
|
<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>
|
</div>
|
||||||
<button @click="convertToDms" class="btn btn-primary btn-full mt-md">转换为度分秒</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CRS Transformation -->
|
<div v-if="projConfig.direction === 'toPlane'" class="flex-col pb-md">
|
||||||
<div class="card">
|
<div class="grid grid-2 gap-md mb-md">
|
||||||
<h3 class="card-title">
|
<div class="input-group">
|
||||||
<MapPin class="icon-md" /> 坐标系转换 (CRS)
|
<label class="input-label">经度 (Lon)</label>
|
||||||
</h3>
|
<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 class="input-group">
|
<div v-else class="flex-col pb-md">
|
||||||
<label class="input-label">输入坐标 (经度, 纬度)</label>
|
<div class="grid grid-2 gap-md mb-md">
|
||||||
<input type="text" v-model="crsInput" class="input" placeholder="例: 116.397, 39.908">
|
<div class="input-group">
|
||||||
</div>
|
<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">
|
<div class="grid grid-2 gap-md mb-md align-center">
|
||||||
<div class="input-group">
|
<label class="checkbox-label">
|
||||||
<label class="input-label">来源坐标系</label>
|
<input type="checkbox" v-model="projConfig.withZone"> 东移坐标包含带号
|
||||||
<select v-model="sourceCrs" class="input">
|
</label>
|
||||||
<option value="WGS84">WGS84 (大地坐标)</option>
|
<div class="input-group m-0" v-if="!projConfig.withZone">
|
||||||
<option value="GCJ02">GCJ02 (火星坐标)</option>
|
<input type="number" v-model.number="projConfig.manualZone" class="input" placeholder="请手动输入区域带号">
|
||||||
<option value="BD09">BD09 (百度坐标)</option>
|
</div>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
<button @click="doProjection" class="btn btn-primary btn-full">反投到经纬度</button>
|
||||||
<div class="input-group">
|
</div>
|
||||||
<label class="input-label">目标坐标系</label>
|
</div>
|
||||||
<select v-model="targetCrs" class="input">
|
</div>
|
||||||
<option value="GCJ02">GCJ02 (火星坐标)</option>
|
|
||||||
<option value="WGS84">WGS84 (大地坐标)</option>
|
<!-- 4. Execute Bulk Conversion -->
|
||||||
<option value="BD09">BD09 (百度坐标)</option>
|
<div v-show="activeTab === 'batch'">
|
||||||
</select>
|
<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>
|
||||||
|
|
||||||
<button @click="transformCrs" class="btn btn-primary btn-full mt-md">执行转换</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results Area -->
|
<!-- Single Result Area -->
|
||||||
<div v-if="result" class="card mt-xl">
|
<div v-if="result && activeTab !== 'batch'" class="card mt-xl">
|
||||||
<div class="flex justify-between items-center mb-md">
|
<div class="flex justify-between items-center mb-md">
|
||||||
<h4 class="m-0 flex items-center gap-xs">
|
<h4 class="m-0 flex items-center gap-xs">
|
||||||
<CheckCircle2 class="icon-md text-success" /> 转换结果
|
<CheckCircle2 class="icon-md text-success" /> 单点计算结果
|
||||||
</h4>
|
</h4>
|
||||||
<button @click="copyResult" class="btn btn-secondary btn-sm">
|
<button @click="copyResult" class="btn btn-secondary btn-sm flex-center gap-xs">
|
||||||
<Copy class="icon-sm" /> 复制结果
|
<Copy class="icon-sm" /> 复制
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -102,10 +223,20 @@
|
|||||||
<p><strong>经度:</strong> {{ result.lon }}</p>
|
<p><strong>经度:</strong> {{ result.lon }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="result.type === 'crs'">
|
<div v-else-if="result.type === 'crs'">
|
||||||
<p><strong>经度 (Lon):</strong> {{ result.lon.toFixed(8) }}</p>
|
<p><strong>经度 (Lon):</strong> {{ result.lon.toFixed(8) }}°</p>
|
||||||
<p><strong>纬度 (Lat):</strong> {{ result.lat.toFixed(8) }}</p>
|
<p><strong>纬度 (Lat):</strong> {{ result.lat.toFixed(8) }}°</p>
|
||||||
<small class="text-tertiary">由 {{ sourceCrs }} 转换为 {{ targetCrs }}</small>
|
<small class="text-tertiary">由 {{ sourceCrs }} 转换为 {{ targetCrs }}</small>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,31 +245,56 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import gcoord from 'gcoord'
|
import gcoord from 'gcoord'
|
||||||
import { ChevronLeft, Globe, RefreshCw, MapPin, Copy, CheckCircle2 } from 'lucide-vue-next'
|
import * as xlsx from 'xlsx'
|
||||||
import { dmsToDecimal, decimalToDms, formatCoordinate } from '../utils/coordinate'
|
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({
|
const dms = ref({
|
||||||
lat: { degrees: 39, minutes: 54, seconds: 27, direction: 'N' },
|
lat: { degrees: 22, minutes: 49, seconds: 16.8640, direction: 'N' },
|
||||||
lon: { degrees: 116, minutes: 23, seconds: 50, direction: 'E' }
|
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 crsInput = ref('116.397451, 39.909235')
|
||||||
const sourceCrs = ref('WGS84')
|
const sourceCrs = ref('WGS84')
|
||||||
const targetCrs = ref('GCJ02')
|
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)
|
const result = ref(null)
|
||||||
|
|
||||||
|
// --- Methods ---
|
||||||
|
|
||||||
const convertToDecimal = () => {
|
const convertToDecimal = () => {
|
||||||
const latDecimal = dmsToDecimal(dms.value.lat.degrees, dms.value.lat.minutes, dms.value.lat.seconds)
|
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)
|
const lonDecimal = dmsToDecimal(dms.value.lon.degrees, dms.value.lon.minutes, dms.value.lon.seconds)
|
||||||
|
|
||||||
result.value = {
|
result.value = {
|
||||||
type: 'decimal',
|
type: 'decimal',
|
||||||
lat: (dms.value.lat.direction === 'S' ? -latDecimal : latDecimal).toFixed(6),
|
lat: (dms.value.lat.direction === 'S' ? -latDecimal : latDecimal).toFixed(8),
|
||||||
lon: (dms.value.lon.direction === 'W' ? -lonDecimal : lonDecimal).toFixed(6)
|
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 = () => {
|
const copyResult = () => {
|
||||||
let text = ''
|
let text = ''
|
||||||
if (result.value.type === 'decimal') {
|
if (result.value.type === 'decimal') {
|
||||||
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
|
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
|
||||||
} else if (result.value.type === 'dms') {
|
} else if (result.value.type === 'dms') {
|
||||||
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
|
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)}`
|
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(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
alert('已复制到剪贴板!')
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.container.pb-2xl {
|
||||||
|
padding-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-header {
|
.tool-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: var(--spacing-xl);
|
margin-bottom: var(--spacing-xl);
|
||||||
@@ -207,15 +560,108 @@ const copyResult = () => {
|
|||||||
color: var(--primary);
|
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 {
|
.dms-input {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr 1fr 0.8fr;
|
|
||||||
gap: var(--spacing-xs);
|
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-sm { width: 16px; height: 16px; }
|
||||||
.icon-md { width: 20px; height: 20px; }
|
.icon-md { width: 20px; height: 20px; }
|
||||||
.icon-lg { width: 24px; height: 24px; vertical-align: bottom; }
|
.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 {
|
.result-box {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -231,12 +677,33 @@ const copyResult = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-full { width: 100%; }
|
.btn-full { width: 100%; }
|
||||||
|
|
||||||
.text-success { color: #10b981; }
|
.text-success { color: #10b981; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.upload-area {
|
||||||
.dms-input {
|
margin-top: var(--spacing-lg);
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
|
|||||||
const Δλ = (lon2 - lon1) * Math.PI / 180;
|
const Δλ = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
|
||||||
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||||
Math.cos(φ1) * Math.cos(φ2) *
|
Math.cos(φ1) * Math.cos(φ2) *
|
||||||
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
return R * c;
|
return R * c;
|
||||||
@@ -79,5 +79,5 @@ export function formatCoordinate(decimal, type) {
|
|||||||
? (decimal >= 0 ? 'N' : 'S')
|
? (decimal >= 0 ? 'N' : 'S')
|
||||||
: (decimal >= 0 ? 'E' : 'W');
|
: (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