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 @@
-
- 顶点坐标 (经度, 纬度)
-
-
+
+
+ 顶点坐标 (经度, 纬度)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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 @@
-
+
-
-
-
-
- 格式转换 (DMS ↔ Decimal)
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
常用地图坐标系互转
+
-
-
+
+
+
+
高斯-克吕格平面投影计算
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- 坐标系转换 (CRS)
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
通过 Excel 批量互转平面与地理坐标
+
+ 请先下载对应的格式模板,填写数据后上传即可自动解析、计算并导出。所有计算均在浏览器本地完成。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
- 转换结果
+ 单点计算结果
-
@@ -102,10 +223,20 @@
经度: {{ result.lon }}
-
经度 (Lon): {{ result.lon.toFixed(8) }}
-
纬度 (Lat): {{ result.lat.toFixed(8) }}
+
经度 (Lon): {{ result.lon.toFixed(8) }}°
+
纬度 (Lat): {{ result.lat.toFixed(8) }}°
由 {{ sourceCrs }} 转换为 {{ targetCrs }}
+
+
北移坐标 (国内X): {{ result.northing.toFixed(4) }} m
+
东移坐标 (国内Y): {{ result.easting.toFixed(4) }} m
+
基于 CGCS2000 {{ projConfig.zoneType }}度带高斯投影 (带号: {{ result.zone }}, 中央子午线: {{ result.meridian }}°)
+
+
+
经度 (Lon): {{ result.lng.toFixed(8) }}°
+
纬度 (Lat): {{ result.lat.toFixed(8) }}°
+
基于 CGCS2000 {{ projConfig.zoneType }}度带高斯投影 (带号: {{ result.zone }}, 中央子午线: {{ result.meridian }}°)
+
@@ -114,31 +245,56 @@
diff --git a/src/utils/coordinate.js b/src/utils/coordinate.js
index fb01851..697eaa3 100644
--- a/src/utils/coordinate.js
+++ b/src/utils/coordinate.js
@@ -24,7 +24,7 @@ export function decimalToDms(decimal) {
const minutesDecimal = (absDecimal - degrees) * 60;
const minutes = Math.floor(minutesDecimal);
const seconds = (minutesDecimal - minutes) * 60;
-
+
return {
degrees: decimal < 0 ? -degrees : degrees,
minutes,
@@ -48,8 +48,8 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
const Δλ = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
- Math.cos(φ1) * Math.cos(φ2) *
- Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
+ Math.cos(φ1) * Math.cos(φ2) *
+ Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
@@ -75,9 +75,9 @@ export function planarDistance(x1, y1, x2, y2) {
*/
export function formatCoordinate(decimal, type) {
const dms = decimalToDms(decimal);
- const direction = type === 'lat'
+ const direction = type === 'lat'
? (decimal >= 0 ? 'N' : 'S')
: (decimal >= 0 ? 'E' : 'W');
-
- return `${Math.abs(dms.degrees)}° ${dms.minutes}' ${dms.seconds.toFixed(2)}" ${direction}`;
+
+ return `${Math.abs(dms.degrees)}°${dms.minutes}'${dms.seconds.toFixed(4)}" ${direction}`;
}
diff --git a/src/utils/proj.js b/src/utils/proj.js
new file mode 100644
index 0000000..42a29f6
--- /dev/null
+++ b/src/utils/proj.js
@@ -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 };
+}