From 8bf55b67165f3a74fdfd2de68ae952cc8ca934f0 Mon Sep 17 00:00:00 2001 From: missum Date: Wed, 18 Mar 2026 23:01:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=E9=9D=A2=E7=A7=AF?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E5=99=A8=E5=92=8C=E5=9D=90=E6=A0=87=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E5=99=A8=E7=BB=84=E4=BB=B6=EF=BC=8C=E5=B9=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=96=B0=E7=9A=84=E4=BE=9D=E8=B5=96=E9=A1=B9=E5=92=8C?= =?UTF-8?q?=E5=AE=9E=E7=94=A8=E5=87=BD=E6=95=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 132 +++++- package.json | 4 +- src/components/AreaCalculator.vue | 151 ++++-- src/components/CoordinateConverter.vue | 633 +++++++++++++++++++++---- src/utils/coordinate.js | 12 +- src/utils/proj.js | 83 ++++ 6 files changed, 892 insertions(+), 123 deletions(-) create mode 100644 src/utils/proj.js diff --git a/package-lock.json b/package-lock.json index 9631be9..a70b025 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index 68c44f7..7c3cce9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/AreaCalculator.vue b/src/components/AreaCalculator.vue index 76be3a8..60ae36d 100644 --- a/src/components/AreaCalculator.vue +++ b/src/components/AreaCalculator.vue @@ -13,16 +13,45 @@
-

- 顶点坐标 (经度, 纬度) - -

+
+

+ 顶点坐标 (经度, 纬度) +

+ +
+ +
+
+ + +
+
+ + +
+
{{ index + 1 }} - - + + + + @@ -30,13 +59,6 @@
-
- - -
@@ -87,44 +109,109 @@ import * as turf from '@turf/turf' import { ChevronLeft, SquareStack, LayoutList, Trash2, Activity, Info } from 'lucide-vue-next' const points = ref([ - { lng: 116.391, lat: 39.907 }, - { lng: 116.401, lat: 39.907 }, - { lng: 116.401, lat: 39.917 }, - { lng: 116.391, lat: 39.917 } + { lng: 108.5597255, lat: 22.8589543, x: 36557444.57, y: 2529026.75 }, + { lng: 108.5607409, lat: 22.8589507, x: 36557548.78, y: 2529026.75 }, + { lng: 108.5607373, lat: 22.8580860, x: 36557548.78, y: 2528930.99 }, + { lng: 108.5597220, lat: 22.8580896, x: 36557444.57, y: 2528930.99 } ]) -const calcMode = ref('geodetic') +const coordType = ref('lnglat') +const calcMethod = ref('geodetic') const area = ref(null) const perimeter = ref(0) +const handleModeChange = () => { + area.value = null + perimeter.value = 0 + if (coordType.value === 'planar') { + calcMethod.value = 'planar' + } else { + calcMethod.value = 'geodetic' + } +} + const addPoint = () => { const last = points.value[points.value.length - 1] - points.value.push({ lng: last.lng + 0.01, lat: last.lat }) + points.value.push({ + lng: last.lng !== undefined ? Number(last.lng) + 0.001 : 116.391, + lat: last.lat !== undefined ? Number(last.lat) : 39.907, + x: last.x !== undefined ? Number(last.x) + 100 : 4419500, + y: last.y !== undefined ? Number(last.y) : 39533900 + }) } const removePoint = (index) => { points.value.splice(index, 1) } -const calculate = () => { - // Turf needs the first and last point to be the same to close the polygon - const coords = points.value.map(p => [p.lng, p.lat]) - coords.push(coords[0]) - - try { - const polygon = turf.polygon([coords]) +const calculatePlanar = (pts) => { + let a = 0; + let p = 0; + const n = pts.length; + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + // Shoelace: sum(x_i * y_{i+1} - x_{i+1} * y_i) + a += pts[i].x * pts[j].y; + a -= pts[j].x * pts[i].y; - if (calcMode.value === 'geodetic') { + // Perimeter + const dx = pts[j].x - pts[i].x; + const dy = pts[j].y - pts[i].y; + p += Math.sqrt(dx*dx + dy*dy); + } + return { area: Math.abs(a / 2), perimeter: p }; +} + +const calculate = () => { + try { + // Ensure all inputs are numbers + const rawPoints = points.value.map(p => ({ + lng: Number(p.lng || 0), + lat: Number(p.lat || 0), + x: Number(p.x || 0), + y: Number(p.y || 0) + })); + + if (coordType.value === 'lnglat' && calcMethod.value === 'geodetic') { + const coords = rawPoints.map(p => [p.lng, p.lat]) + coords.push(coords[0]) + const polygon = turf.polygon([coords]) area.value = turf.area(polygon) perimeter.value = turf.length(polygon, { units: 'meters' }) } else { - // Simple planar area as fallback/comparison - area.value = Math.abs(turf.area(polygon)) // Turf area is always geodetic, for true planar you'd need a custom formula - // Note: Turf.js doesn't have a dedicated "planar" area for user-defined units, - // but we can simulate it or just use geodetic as it's more accurate for surveying. + let pts = []; + if (coordType.value === 'planar') { + // *** 关键改进:使用相对坐标偏移,防止大数计算丢失精度 *** + const x0 = rawPoints[0].x; + const y0 = rawPoints[0].y; + pts = rawPoints.map(p => ({ x: p.x - x0, y: p.y - y0 })); + } else { + // Approximate LngLat to meters using Equirectangular projection at local latitude + const latAvg = rawPoints.reduce((acc, cur) => acc + cur.lat, 0) / rawPoints.length; + const radLat = latAvg * Math.PI / 180; + const kx = 111319.49 * Math.cos(radLat); + const ky = 111319.49; + + // Use relative coordinates to avoid large number precision issues with degree calculation + const lng0 = rawPoints[0].lng; + const lat0 = rawPoints[0].lat; + + pts = rawPoints.map(p => ({ + x: (p.lng - lng0) * kx, + y: (p.lat - lat0) * ky + })); + } + + if (pts.length < 3) { + alert('顶点数量不足') + return + } + const res = calculatePlanar(pts) + area.value = res.area + perimeter.value = res.perimeter } } catch (e) { - alert('多边形无效,请检查坐标点顺序(需按顺时针或逆时针排列且不能自相交)') + alert('计算失败: ' + e.message) } } diff --git a/src/components/CoordinateConverter.vue b/src/components/CoordinateConverter.vue index 5331c49..6e40753 100644 --- a/src/components/CoordinateConverter.vue +++ b/src/components/CoordinateConverter.vue @@ -1,94 +1,215 @@