refactor: harden deps, fix geo math, and add engineering tooling

Replace xlsx with exceljs, unify CRS/DMS logic, move pages to views with shared map/toast composables, and add Vitest/ESLint/Prettier plus docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-12 09:26:15 +08:00
co-authored by Cursor
parent be48495dcf
commit 88e201e50b
29 changed files with 3270 additions and 2756 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"tabWidth": 4,
"useTabs": false,
"endOfLine": "auto",
"vueIndentScriptAndStyle": false
}
+70 -3
View File
@@ -1,5 +1,72 @@
# Vue 3 + Vite
# 测绘工具箱 (geo-tools)
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
面向测绘与 GIS 日常工作的纯前端工具集,基于 Vue 3 + Vite 构建。支持坐标转换、距离/面积/方位角/高程计算、高德地名搜索、图上量测拾取与坐标展点。
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
## 功能列表
| 工具 | 说明 |
|------|------|
| 坐标转换 | 度分秒与十进制互转、WGS84/GCJ-02 等坐标系转换、CGCS2000 高斯投影正反算,支持 Excel 批量 |
| 距离计算 | 经纬度 Haversine 与平面坐标距离 |
| 面积计算 | 多边形面积/周长,支持大地测量与平面算法 |
| 角度转换 | 度分秒、十进制度、弧度互转 |
| 方位角计算 | 方位角与象限角 |
| 高程计算 | 高差、坡度、坡度角、斜距/水平距换算 |
| 高德地名搜索 | 通过高德 Web 服务搜索 POI,导出 TXT/CSV |
| 图上量测与拾取 | Leaflet 地图点选、量距、量面,显示 WGS-84 与投影坐标 |
| 坐标展点 | 手动/批量/Excel 导入坐标并展绘到地图 |
## 技术栈
- Vue 3 + Vue Router + Vite
- Leaflet(地图)、proj4 / gcoord(投影与坐标系)、Turf 子包(面积/长度)
- ExcelJSExcel 读写)
- Vitest + ESLint + Prettier
## 本地开发
```bash
npm install
npm run dev
```
默认开发地址:`http://localhost:5173/geo-tools/`(已配置 `base: '/geo-tools/'`)。
## 常用脚本
```bash
npm run dev # 开发服务器
npm run build # 生产构建
npm run preview # 预览构建结果
npm run test # 单元测试
npm run lint # ESLint 检查
```
## 部署说明
项目构建产物默认部署在子路径 `/geo-tools/` 下(见 `vite.config.js``base` 与路由 `createWebHistory('/geo-tools/')`)。
常见部署方式:
1.`npm run build` 生成的 `dist/` 上传到静态站点对应子目录(如 GitHub Pages 的 `/geo-tools/`
2. 若部署到站点根路径,需同步把 `base` 与路由器 history base 改为 `'/'`
### 注意事项
- **高德瓦片**:图上量测与坐标展点使用的高德栅格瓦片为非官方接口,公开长期部署时建议改为天地图等正规授权底图,或自行申请合规底图服务。
- **高德 Web API Key**:地名搜索功能的 Key 保存在浏览器 `localStorage`。公开使用前请在高德控制台配置域名白名单与配额限制。
- 坐标转换与投影计算在浏览器本地完成,不上传坐标数据到自建后端。
## 目录结构(简要)
```
src/
views/ # 路由页面
composables/ # useLeafletMap、useToast 等
utils/ # 坐标、投影、几何、Excel 等纯函数
assets/css/ # 全局样式与主题变量
```
## License
Private / 按项目需要自行约定。
+31
View File
@@ -0,0 +1,31 @@
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import eslintConfigPrettier from 'eslint-config-prettier'
import globals from 'globals'
export default [
{
ignores: ['dist/**', 'node_modules/**']
},
js.configs.recommended,
...pluginVue.configs['flat/recommended'],
eslintConfigPrettier,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node
}
},
rules: {
'vue/multi-word-component-names': 'off',
'vue/require-default-prop': 'off',
'vue/attributes-order': 'off',
'vue/first-attribute-linebreak': 'off',
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
'no-console': 'off'
}
}
]
+2403 -2245
View File
File diff suppressed because it is too large Load Diff
+19 -5
View File
@@ -6,20 +6,34 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{js,vue,css,md}\""
},
"dependencies": {
"@turf/turf": "^7.3.3",
"@turf/area": "^7.4.0",
"@turf/helpers": "^7.4.0",
"@turf/length": "^7.4.0",
"exceljs": "^4.4.0",
"gcoord": "^1.0.7",
"leaflet": "^1.9.4",
"lucide-vue-next": "^0.563.0",
"proj4": "^2.20.4",
"vue": "^3.5.24",
"vue-router": "^4.6.4",
"xlsx": "^0.18.5"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.2.4"
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.10.0",
"globals": "^17.9.0",
"prettier": "^3.9.6",
"vite": "^7.2.4",
"vitest": "^4.1.10"
}
}
+8 -4
View File
@@ -4,12 +4,13 @@
<div class="container">
<div class="nav-content">
<router-link to="/" class="logo">
<span class="logo-icon">📐</span>
<Triangle class="logo-icon icon-gradient" />
<span class="logo-text">测绘工具箱</span>
</router-link>
<button @click="toggleTheme" class="theme-toggle" aria-label="切换主题">
{{ isDark ? '🌞' : '🌙' }}
<Sun v-if="isDark" class="icon-lg icon-warning" />
<Moon v-else class="icon-lg icon-primary" />
</button>
</div>
</div>
@@ -33,6 +34,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { Triangle, Sun, Moon } from 'lucide-vue-next'
const isDark = ref(false)
@@ -44,10 +46,12 @@ const toggleTheme = () => {
onMounted(() => {
const savedTheme = localStorage.getItem('theme')
isDark.value = savedTheme === 'dark'
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme)
isDark.value = savedTheme === 'dark'
} else {
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
})
</script>
+57
View File
@@ -322,6 +322,63 @@ p {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Icon Colors */
.icon-primary {
color: var(--primary);
}
.icon-secondary {
color: var(--secondary);
}
.icon-accent {
color: var(--accent);
}
.icon-success {
color: var(--success);
}
.icon-warning {
color: var(--warning);
}
.icon-danger {
color: var(--danger);
}
.icon-gradient {
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.icon-size-xs {
width: 12px;
height: 12px;
}
.icon-size-sm {
width: 16px;
height: 16px;
}
.icon-size-md {
width: 20px;
height: 20px;
}
.icon-size-lg {
width: 24px;
height: 24px;
}
.icon-size-xl {
width: 32px;
height: 32px;
}
/* Utility Classes */
.text-center {
text-align: center;
+58
View File
@@ -0,0 +1,58 @@
import { onBeforeUnmount } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const AMAP_TILE_URL =
'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}'
/**
* Leaflet 地图初始化与销毁
* @param {import('vue').Ref<HTMLElement|null>} mapRef
* @param {{ center?: [number, number], zoom?: number, zoomControl?: boolean, doubleClickZoom?: boolean }} options
*/
export function useLeafletMap(mapRef, options = {}) {
let map = null
function createMap() {
if (!mapRef.value) {
return null
}
map = L.map(mapRef.value, {
center: options.center ?? [30.0, 104.0],
zoom: options.zoom ?? 5,
zoomControl: options.zoomControl ?? true,
doubleClickZoom: options.doubleClickZoom ?? true
})
L.tileLayer(AMAP_TILE_URL, {
subdomains: ['1', '2', '3', '4'],
maxZoom: 18,
attribution: '&copy; 高德地图'
}).addTo(map)
return map
}
function destroyMap() {
if (map) {
map.remove()
map = null
}
}
function getMap() {
return map
}
onBeforeUnmount(() => {
destroyMap()
})
return {
createMap,
destroyMap,
getMap,
L
}
}
+24
View File
@@ -0,0 +1,24 @@
import { ref } from 'vue'
/**
* 统一消息提示
* @param {number} duration - 显示时长(毫秒)
*/
export function useToast(duration = 3000) {
const message = ref('')
const messageType = ref('success')
function showMessage(msg, isError = false) {
message.value = msg
messageType.value = isError ? 'error' : 'success'
setTimeout(() => {
message.value = ''
}, duration)
}
return {
message,
messageType,
showMessage
}
}
+10 -20
View File
@@ -1,65 +1,55 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from './views/HomeView.vue'
import CoordinateConverter from './components/CoordinateConverter.vue'
import DistanceCalculator from './components/DistanceCalculator.vue'
import AreaCalculator from './components/AreaCalculator.vue'
import AngleConverter from './components/AngleConverter.vue'
import BearingCalculator from './components/BearingCalculator.vue'
import ElevationCalculator from './components/ElevationCalculator.vue'
import AmapSearchTool from './components/AmapSearchTool.vue'
import MapInteraction from './components/MapInteraction.vue'
import CoordinatePlotter from './components/CoordinatePlotter.vue'
const routes = [
{
path: '/',
name: 'Home',
component: HomeView
component: () => import('./views/HomeView.vue')
},
{
path: '/coordinate-converter',
name: 'CoordinateConverter',
component: CoordinateConverter
component: () => import('./views/CoordinateConverter.vue')
},
{
path: '/distance-calculator',
name: 'DistanceCalculator',
component: DistanceCalculator
component: () => import('./views/DistanceCalculator.vue')
},
{
path: '/area-calculator',
name: 'AreaCalculator',
component: AreaCalculator
component: () => import('./views/AreaCalculator.vue')
},
{
path: '/angle-converter',
name: 'AngleConverter',
component: AngleConverter
component: () => import('./views/AngleConverter.vue')
},
{
path: '/bearing-calculator',
name: 'BearingCalculator',
component: BearingCalculator
component: () => import('./views/BearingCalculator.vue')
},
{
path: '/elevation-calculator',
name: 'ElevationCalculator',
component: ElevationCalculator
component: () => import('./views/ElevationCalculator.vue')
},
{
path: '/amap-search',
name: 'AmapSearch',
component: AmapSearchTool
component: () => import('./views/AmapSearchTool.vue')
},
{
path: '/map-interaction',
name: 'MapInteraction',
component: MapInteraction
component: () => import('./views/MapInteraction.vue')
},
{
path: '/coordinate-plotter',
name: 'CoordinatePlotter',
component: CoordinatePlotter
component: () => import('./views/CoordinatePlotter.vue')
}
]
+9 -66
View File
@@ -1,72 +1,15 @@
/**
* 高德地图坐标转换工具函数
* GCJ-02 (火星坐标系) 转 WGS-84 (GPS坐标系)
*/
const PI = 3.14159265358979324;
const a = 6378245.0; // 地球长半轴
const ee = 0.00669342162296594323; // 扁率
/**
* 检查坐标是否在国外
*/
function outOfChina(lng, lat) {
if (lng < 72.004 || lng > 137.8347) {
return true;
}
if (lat < 0.8293 || lat > 55.8271) {
return true;
}
return false;
}
/**
* 纬度转换
*/
function transformLat(x, y) {
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * PI) + 320.0 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0;
return ret;
}
/**
* 经度转换
*/
function transformLng(x, y) {
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0;
return ret;
}
import gcoord from 'gcoord'
/**
* GCJ-02 转 WGS-84
* @param {number} lng - GCJ-02经度
* @param {number} lat - GCJ-02纬度
* @returns {Array} [WGS-84经度, WGS-84纬度]
* @param {number} lng - GCJ-02 经度
* @param {number} lat - GCJ-02 纬度
* @returns {[number, number]} [WGS-84 经度, WGS-84 纬度]
*/
export function gcj02ToWgs84(lng, lat) {
// 检查是否在国内
if (outOfChina(lng, lat)) {
return [lng, lat];
}
let dLat = transformLat(lng - 105.0, lat - 35.0);
let dLng = transformLng(lng - 105.0, lat - 35.0);
const radLat = lat / 180.0 * PI;
let magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * PI);
dLng = (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * PI);
const wgsLat = lat - dLat;
const wgsLng = lng - dLng;
return [parseFloat(wgsLng.toFixed(6)), parseFloat(wgsLat.toFixed(6))];
const [wgsLng, wgsLat] = gcoord.transform([lng, lat], gcoord.GCJ02, gcoord.WGS84)
return [
parseFloat(Number(wgsLng).toFixed(6)),
parseFloat(Number(wgsLat).toFixed(6))
]
}
+38 -27
View File
@@ -10,7 +10,7 @@
* @returns {number} 十进制度数
*/
export function dmsToDecimal(degrees, minutes, seconds) {
return degrees + minutes / 60 + seconds / 3600;
return degrees + minutes / 60 + seconds / 3600;
}
/**
@@ -19,17 +19,28 @@ export function dmsToDecimal(degrees, minutes, seconds) {
* @returns {{degrees: number, minutes: number, seconds: number}}
*/
export function decimalToDms(decimal) {
const absDecimal = Math.abs(decimal);
const degrees = Math.floor(absDecimal);
const minutesDecimal = (absDecimal - degrees) * 60;
const minutes = Math.floor(minutesDecimal);
const seconds = (minutesDecimal - minutes) * 60;
const isNegative = decimal < 0;
const absDecimal = Math.abs(decimal);
let degrees = Math.floor(absDecimal);
let minutesDecimal = (absDecimal - degrees) * 60;
let minutes = Math.floor(minutesDecimal);
let seconds = (minutesDecimal - minutes) * 60;
return {
degrees: decimal < 0 ? -degrees : degrees,
minutes,
seconds: parseFloat(seconds.toFixed(6))
};
seconds = parseFloat(seconds.toFixed(6));
if (seconds >= 60) {
seconds = 0;
minutes += 1;
}
if (minutes >= 60) {
minutes = 0;
degrees += 1;
}
return {
degrees: isNegative ? -degrees : degrees,
minutes,
seconds
};
}
/**
@@ -41,18 +52,18 @@ export function decimalToDms(decimal) {
* @returns {number} 距离(米)
*/
export function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371000; // 地球半径(米)
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const R = 6371000; // 地球半径(米)
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
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);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const a = 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;
return R * c;
}
/**
@@ -64,7 +75,7 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
* @returns {number} 距离
*/
export function planarDistance(x1, y1, x2, y2) {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
}
/**
@@ -74,10 +85,10 @@ export function planarDistance(x1, y1, x2, y2) {
* @returns {string} 格式化的坐标字符串
*/
export function formatCoordinate(decimal, type) {
const dms = decimalToDms(decimal);
const direction = type === 'lat'
? (decimal >= 0 ? 'N' : 'S')
: (decimal >= 0 ? 'E' : 'W');
const dms = decimalToDms(decimal);
const direction = type === 'lat'
? (decimal >= 0 ? 'N' : 'S')
: (decimal >= 0 ? 'E' : 'W');
return `${Math.abs(dms.degrees)}°${dms.minutes}'${dms.seconds.toFixed(4)}" ${direction}`;
return `${Math.abs(dms.degrees)}°${dms.minutes}'${dms.seconds.toFixed(4)}" ${direction}`;
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import {
dmsToDecimal,
decimalToDms,
haversineDistance,
formatCoordinate
} from '../utils/coordinate.js'
describe('dmsToDecimal / decimalToDms', () => {
it('converts DMS to decimal', () => {
expect(dmsToDecimal(39, 54, 27)).toBeCloseTo(39.9075, 4)
})
it('converts decimal to DMS', () => {
const result = decimalToDms(39.9075)
expect(result.degrees).toBe(39)
expect(result.minutes).toBe(54)
expect(result.seconds).toBeCloseTo(27, 2)
})
it('preserves sign for values between -1 and 0', () => {
const result = decimalToDms(-0.5)
expect(result.degrees).toBe(-0)
expect(Object.is(result.degrees, -0) || result.degrees < 0 || Math.abs(result.degrees) === 0).toBe(true)
expect(formatCoordinate(-0.5, 'lat')).toContain('S')
expect(formatCoordinate(-0.5, 'lon')).toContain('W')
})
it('normalizes seconds that round to 60', () => {
// 1° + (59 + 59.9999995/60)/60 ≈ value that may round seconds to 60
const almost = 1 + (59 + 59.9999995 / 60) / 60
const result = decimalToDms(almost)
expect(result.seconds).toBeLessThan(60)
expect(result.minutes).toBeLessThan(60)
})
it('round-trips positive coordinates', () => {
const original = 108.310394
const dms = decimalToDms(original)
const back = dmsToDecimal(Math.abs(dms.degrees), dms.minutes, dms.seconds)
expect(back).toBeCloseTo(original, 5)
})
})
describe('haversineDistance', () => {
it('returns ~0 for identical points', () => {
expect(haversineDistance(30, 104, 30, 104)).toBeCloseTo(0, 5)
})
it('computes known short distance approximately', () => {
// Roughly 1 degree latitude ≈ 111km near equator
const dist = haversineDistance(0, 0, 1, 0)
expect(dist).toBeGreaterThan(110000)
expect(dist).toBeLessThan(112000)
})
})
+118
View File
@@ -0,0 +1,118 @@
import ExcelJS from 'exceljs'
function normalizeCellValue(value) {
if (value == null) {
return value
}
if (typeof value === 'object') {
if (value instanceof Date) {
return value
}
if ('result' in value) {
return value.result
}
if ('text' in value) {
return value.text
}
if ('richText' in value && Array.isArray(value.richText)) {
return value.richText.map((part) => part.text).join('')
}
}
return value
}
function downloadBuffer(buffer, filename) {
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
/**
* 读取 Excel 首个工作表,返回与 sheet_to_json 等价的对象数组
* @param {ArrayBuffer} arrayBuffer
* @returns {Promise<{ rows: Array<Record<string, unknown>>, columns: string[] }>}
*/
export async function readExcelAsJson(arrayBuffer) {
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.load(arrayBuffer)
const worksheet = workbook.worksheets[0]
if (!worksheet || worksheet.rowCount === 0) {
return { rows: [], columns: [] }
}
const headerRow = worksheet.getRow(1)
const headers = []
headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => {
const header = normalizeCellValue(cell.value)
headers[colNumber - 1] = header != null && header !== ''
? String(header)
: `Column${colNumber}`
})
const rows = []
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
if (rowNumber === 1) {
return
}
const record = {}
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
const header = headers[colNumber - 1]
if (header) {
record[header] = normalizeCellValue(cell.value)
}
})
rows.push(record)
})
return {
rows,
columns: rows.length > 0 ? Object.keys(rows[0]) : headers.filter(Boolean)
}
}
/**
* 将对象数组导出为 Excel 文件
* @param {Array<Record<string, unknown>>} rows
* @param {string} filename
* @param {string} sheetName
*/
export async function writeJsonToExcel(rows, filename, sheetName = 'Sheet1') {
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet(sheetName)
if (rows.length === 0) {
const buffer = await workbook.xlsx.writeBuffer()
downloadBuffer(buffer, filename)
return
}
const columns = Object.keys(rows[0])
worksheet.columns = columns.map((key) => ({ header: key, key }))
rows.forEach((row) => worksheet.addRow(row))
const buffer = await workbook.xlsx.writeBuffer()
downloadBuffer(buffer, filename)
}
/**
* 将二维数组导出为 Excel 文件
* @param {Array<Array<unknown>>} data
* @param {string} filename
* @param {string} sheetName
*/
export async function writeAoaToExcel(data, filename, sheetName = 'Sheet1') {
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet(sheetName)
data.forEach((row) => worksheet.addRow(row))
const buffer = await workbook.xlsx.writeBuffer()
downloadBuffer(buffer, filename)
}
+1 -43
View File
@@ -33,9 +33,8 @@ export function calculateBearing(x1, y1, x2, y2) {
if (azimuth < 0) azimuth += 360;
// 计算象限角
let quadrant = '';
const angle = Math.abs(Math.atan2(dx, dy) * 180 / Math.PI);
let quadrant;
if (dx >= 0 && dy >= 0) {
quadrant = `N ${angle.toFixed(2)}° E`;
} else if (dx >= 0 && dy < 0) {
@@ -51,44 +50,3 @@ export function calculateBearing(x1, y1, x2, y2) {
quadrant
};
}
/**
* 计算多边形面积(Shoelace公式)
* @param {Array<{x: number, y: number}>} points - 顶点坐标数组
* @returns {number} 面积
*/
export function calculatePolygonArea(points) {
if (points.length < 3) return 0;
let area = 0;
for (let i = 0; i < points.length; i++) {
const j = (i + 1) % points.length;
area += points[i].x * points[j].y;
area -= points[j].x * points[i].y;
}
return Math.abs(area / 2);
}
/**
* 度分秒转十进制度(角度)
*/
export function dmsAngleToDecimal(degrees, minutes, seconds) {
return degrees + minutes / 60 + seconds / 3600;
}
/**
* 十进制度转度分秒(角度)
*/
export function decimalAngleToDms(decimal) {
const degrees = Math.floor(decimal);
const minutesDecimal = (decimal - degrees) * 60;
const minutes = Math.floor(minutesDecimal);
const seconds = (minutesDecimal - minutes) * 60;
return {
degrees,
minutes,
seconds: parseFloat(seconds.toFixed(6))
};
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { calculateBearing, degToRad, radToDeg } from '../utils/geometry.js'
describe('degToRad / radToDeg', () => {
it('converts 180 degrees to PI radians', () => {
expect(degToRad(180)).toBeCloseTo(Math.PI, 10)
})
it('converts PI radians to 180 degrees', () => {
expect(radToDeg(Math.PI)).toBeCloseTo(180, 10)
})
})
describe('calculateBearing', () => {
it('returns northeast quadrant', () => {
const result = calculateBearing(0, 0, 100, 100)
expect(result.azimuth).toBeCloseTo(45, 1)
expect(result.quadrant).toContain('N')
expect(result.quadrant).toContain('E')
})
it('returns southeast quadrant', () => {
const result = calculateBearing(0, 0, 100, -100)
expect(result.azimuth).toBeCloseTo(135, 1)
expect(result.quadrant).toContain('S')
expect(result.quadrant).toContain('E')
})
it('returns southwest quadrant', () => {
const result = calculateBearing(0, 0, -100, -100)
expect(result.azimuth).toBeCloseTo(225, 1)
expect(result.quadrant).toContain('S')
expect(result.quadrant).toContain('W')
})
it('returns northwest quadrant', () => {
const result = calculateBearing(0, 0, -100, 100)
expect(result.azimuth).toBeCloseTo(315, 1)
expect(result.quadrant).toContain('N')
expect(result.quadrant).toContain('W')
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import {
getCentralMeridian,
projectToPlane,
unprojectToLngLat
} from '../utils/proj.js'
describe('getCentralMeridian', () => {
it('computes 3-degree zone', () => {
const { zone, meridian } = getCentralMeridian(108.5, 3)
expect(zone).toBe(36)
expect(meridian).toBe(108)
})
it('computes 6-degree zone', () => {
const { zone, meridian } = getCentralMeridian(108.5, 6)
expect(zone).toBe(19)
expect(meridian).toBe(111)
})
})
describe('projectToPlane / unprojectToLngLat', () => {
it('round-trips without zone prefix', () => {
const lng = 108.55972199
const lat = 22.85808956
const projected = projectToPlane(lng, lat, 3, false)
const back = unprojectToLngLat(
projected.easting,
projected.northing,
3,
false,
projected.zone
)
expect(back.lng).toBeCloseTo(lng, 6)
expect(back.lat).toBeCloseTo(lat, 6)
})
it('round-trips with zone prefix', () => {
const lng = 116.397451
const lat = 39.909235
const projected = projectToPlane(lng, lat, 3, true)
expect(String(Math.floor(projected.easting)).length).toBeGreaterThanOrEqual(7)
const back = unprojectToLngLat(
projected.easting,
projected.northing,
3,
true
)
expect(back.lng).toBeCloseTo(lng, 6)
expect(back.lat).toBeCloseTo(lat, 6)
expect(back.zone).toBe(projected.zone)
})
})
@@ -146,8 +146,9 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { gcj02ToWgs84 } from '../utils/amap'
import { useToast } from '../composables/useToast'
const apiKey = ref('')
const keyword = ref('人民政府')
@@ -155,23 +156,13 @@ const city = ref('楚雄')
const pageSize = ref(40)
const searchResults = ref([])
const loading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const { message, messageType, showMessage } = useToast(5000)
const errorMessage = computed(() => (messageType.value === 'error' ? message.value : ''))
const successMessage = computed(() => (messageType.value === 'success' && message.value ? message.value : ''))
//
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))
//
const showMessage = (message, isError = false) => {
if (isError) {
errorMessage.value = message
setTimeout(() => { errorMessage.value = '' }, 5000)
} else {
successMessage.value = message
setTimeout(() => { successMessage.value = '' }, 5000)
}
}
// API
const saveApiKey = () => {
const trimmedKey = apiKey.value.trim()
@@ -287,8 +278,7 @@ const performSearch = async () => {
loading.value = true
searchResults.value = []
errorMessage.value = ''
successMessage.value = ''
message.value = ''
try {
const results = []
@@ -82,26 +82,26 @@
<script setup>
import { ref } from 'vue'
import { dmsAngleToDecimal, decimalAngleToDms } from '../utils/geometry'
import { dmsToDecimal, decimalToDms } from '../utils/coordinate'
const dms = ref({ degrees: 45, minutes: 30, seconds: 0 })
const decimal = ref(45.5)
const radian = ref(0.7941)
const fromDms = () => {
decimal.value = dmsAngleToDecimal(dms.value.degrees, dms.value.minutes, dms.value.seconds)
decimal.value = dmsToDecimal(dms.value.degrees, dms.value.minutes, dms.value.seconds)
radian.value = decimal.value * Math.PI / 180
}
const fromDecimal = () => {
const converted = decimalAngleToDms(decimal.value)
const converted = decimalToDms(decimal.value)
dms.value = converted
radian.value = decimal.value * Math.PI / 180
}
const fromRadian = () => {
decimal.value = radian.value * 180 / Math.PI
const converted = decimalAngleToDms(decimal.value)
const converted = decimalToDms(decimal.value)
dms.value = converted
}
@@ -105,7 +105,9 @@
<script setup>
import { ref } from 'vue'
import * as turf from '@turf/turf'
import turfArea from '@turf/area'
import turfLength from '@turf/length'
import { polygon as turfPolygon } from '@turf/helpers'
import { ChevronLeft, SquareStack, LayoutList, Trash2, Activity, Info } from 'lucide-vue-next'
const points = ref([
@@ -175,9 +177,9 @@ const calculate = () => {
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' })
const poly = turfPolygon([coords])
area.value = turfArea(poly)
perimeter.value = turfLength(poly, { units: 'meters' })
} else {
let pts = [];
if (coordType.value === 'planar') {
@@ -245,7 +245,7 @@
<script setup>
import { ref } from 'vue'
import gcoord from 'gcoord'
import * as xlsx from 'xlsx'
import { readExcelAsJson, writeJsonToExcel, writeAoaToExcel } from '../utils/excel'
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'
@@ -358,7 +358,7 @@ const doProjection = () => {
}
const copyResult = () => {
let text = ''
let text
if (result.value.type === 'decimal') {
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
} else if (result.value.type === 'dms') {
@@ -378,7 +378,7 @@ const copyResult = () => {
// === Bulk Excel Conversion ===
const downloadTemplate = (type) => {
const downloadTemplate = async (type) => {
let wsData = []
let filename = ''
if (type === 'toPlane') {
@@ -395,144 +395,136 @@ const downloadTemplate = (type) => {
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)
await writeAoaToExcel(wsData, filename, 'Sheet1')
}
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无数据')
file.arrayBuffer()
.then(async (arrayBuffer) => {
try {
const { rows: json } = await readExcelAsJson(arrayBuffer)
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 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 = []
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)
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'
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'
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 {
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,
'纬度(度分秒)': formatCoordinate(latDec, 'lat'),
'经度(度分秒)': formatCoordinate(lonDec, 'lon')
'纬度(十进制)': latDec.toFixed(8),
'经度(十进制)': lonDec.toFixed(8)
})
} 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
} else if (isToDms) {
const latDec = parseFloat(row['纬度(十进制)'])
const lonDec = parseFloat(row['经度(十进制)'])
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 })
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 (isToLngLat) {
const northing = parseFloat(row['X坐标(北移)']) || parseFloat(row['X坐标'])
const easting = parseFloat(row['Y坐标(东移)']) || parseFloat(row['Y坐标'])
const manualZone = row['手动带号'] ? parseInt(row['手动带号']) : null
} 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
const withZone = easting > 1000000
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
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 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 })
}
}
}
}
await writeJsonToExcel(resultData, `坐标转换结果_${Date.now()}.xlsx`, '转换结果')
// clear input
e.target.value = ''
} catch (err) {
alert('文件解析失败: ' + 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) {
})
.catch((err) => {
alert('文件解析失败: ' + err.message)
}
}
reader.readAsArrayBuffer(file)
})
}
</script>
@@ -2,7 +2,7 @@
<div class="container plotter-container">
<div class="tool-header">
<router-link to="/" class="back-link"> 返回首页</router-link>
<h2>📍 坐标展点</h2>
<h2><MapPin class="icon-lg icon-primary" /> 坐标展点</h2>
<p>将输入或计算得到的坐标批量展绘到地图上直观展示分布</p>
</div>
@@ -47,7 +47,7 @@
:class="['tab-btn', { active: activeTab === tab.key }]"
@click="activeTab = tab.key"
>
{{ tab.icon }} {{ tab.label }}
<component :is="tab.icon" class="icon-sm" :class="tab.iconClass" /> {{ tab.label }}
</button>
</div>
@@ -147,11 +147,11 @@
<!-- Point List -->
<div v-if="points.length > 0" class="points-panel card">
<div class="points-header">
<h3 class="card-title">📊 坐标列表 ({{ points.length }} )</h3>
<h3 class="card-title"><List class="icon-md icon-primary" /> 坐标列表 ({{ points.length }} )</h3>
<div class="points-actions">
<button class="btn btn-secondary btn-sm" @click="fitMapToPoints">🔍 全部定位</button>
<button class="btn btn-secondary btn-sm" @click="exportExcel">📤 导出Excel</button>
<button class="btn btn-secondary btn-sm" @click="clearPoints">🗑 清空</button>
<button class="btn btn-secondary btn-sm" @click="fitMapToPoints"><Search class="icon-sm icon-primary" /> 全部定位</button>
<button class="btn btn-secondary btn-sm" @click="exportExcel"><Download class="icon-sm icon-success" /> 导出Excel</button>
<button class="btn btn-secondary btn-sm" @click="clearPoints"><Trash2 class="icon-sm icon-danger" /> 清空</button>
</div>
</div>
<div class="points-table-wrapper">
@@ -190,16 +190,22 @@
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed, nextTick } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { ref, onMounted, computed } from 'vue'
import { MapPin, List, Search, Download, Trash2, PenTool, FileText, Table } from 'lucide-vue-next'
import { unprojectToLngLat } from '../utils/proj'
import * as XLSX from 'xlsx'
import { readExcelAsJson, writeJsonToExcel } from '../utils/excel'
import gcoord from 'gcoord'
import { useLeafletMap } from '../composables/useLeafletMap'
import { useToast } from '../composables/useToast'
// State
const mapRef = ref(null)
const fileInput = ref(null)
const { createMap, L } = useLeafletMap(mapRef, {
center: [34.0, 108.0],
zoom: 4
})
const { message, messageType: msgType, showMessage: showMsg } = useToast(3500)
let map = null
let markerGroup = null
@@ -227,14 +233,10 @@ const mappingName = ref('')
// Points data (always stored as WGS-84 lng/lat)
const points = ref([])
// Messages
const message = ref('')
const msgType = ref('success')
const tabs = [
{ key: 'manual', icon: '✏️', label: '手动输入' },
{ key: 'batch', icon: '📝', label: '批量粘贴' },
{ key: 'excel', icon: '📊', label: 'Excel导入' }
{ key: 'manual', icon: PenTool, iconClass: 'icon-primary', label: '手动输入' },
{ key: 'batch', icon: FileText, iconClass: 'icon-secondary', label: '批量粘贴' },
{ key: 'excel', icon: Table, iconClass: 'icon-accent', label: 'Excel导入' }
]
const batchPlaceholder = computed(() => {
@@ -245,31 +247,9 @@ const batchPlaceholder = computed(() => {
// Map
onMounted(() => {
initMap()
})
onBeforeUnmount(() => {
if (map) {
map.remove()
map = null
}
})
function initMap() {
map = L.map(mapRef.value, {
center: [34.0, 108.0],
zoom: 4,
zoomControl: true
})
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
subdomains: ['1', '2', '3', '4'],
maxZoom: 18,
attribution: '&copy; 高德地图'
}).addTo(map)
map = createMap()
markerGroup = L.layerGroup().addTo(map)
}
})
// Convert coordinate to WGS-84 lng/lat
function toWgs84(x, y) {
@@ -284,7 +264,7 @@ function toWgs84(x, y) {
)
return { lng: result.lng, lat: result.lat }
} catch (e) {
throw new Error(`投影反算失败: ${e.message}`)
throw new Error(`投影反算失败: ${e.message}`, { cause: e })
}
}
@@ -414,30 +394,27 @@ function handleFileSelect(e) {
}
function readExcelFile(file) {
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result)
const workbook = XLSX.read(data, { type: 'array' })
const sheetName = workbook.SheetNames[0]
const sheet = workbook.Sheets[sheetName]
const json = XLSX.utils.sheet_to_json(sheet)
file.arrayBuffer()
.then(async (arrayBuffer) => {
try {
const { rows } = await readExcelAsJson(arrayBuffer)
if (json.length === 0) {
showMsg('Excel 文件为空', true)
return
if (rows.length === 0) {
showMsg('Excel 文件为空', true)
return
}
excelRawData.value = rows
excelColumns.value = Object.keys(rows[0])
autoDetectColumns()
showMsg(`已读取 ${rows.length} 行数据,请设置列映射`)
} catch (err) {
showMsg(`读取文件失败: ${err.message}`, true)
}
excelRawData.value = json
excelColumns.value = Object.keys(json[0])
// Auto-detect column mapping
autoDetectColumns()
showMsg(`已读取 ${json.length} 行数据,请设置列映射`)
} catch (err) {
})
.catch((err) => {
showMsg(`读取文件失败: ${err.message}`, true)
}
}
reader.readAsArrayBuffer(file)
})
}
function autoDetectColumns() {
@@ -543,7 +520,7 @@ function refreshMarkers() {
}
// Export
function exportExcel() {
async function exportExcel() {
if (points.value.length === 0) {
showMsg('没有可导出的数据', true)
return
@@ -556,19 +533,9 @@ function exportExcel() {
'WGS-84纬度': p.lat.toFixed(6)
}))
const ws = XLSX.utils.json_to_sheet(rows)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '坐标')
XLSX.writeFile(wb, `坐标展点_${new Date().toISOString().slice(0, 10)}.xlsx`)
await writeJsonToExcel(rows, `坐标展点_${new Date().toISOString().slice(0, 10)}.xlsx`, '坐标')
showMsg(`已导出 ${points.value.length} 个坐标点`)
}
// Messages
function showMsg(msg, isError = false) {
message.value = msg
msgType.value = isError ? 'error' : 'success'
setTimeout(() => { message.value = '' }, 3500)
}
</script>
<style scoped>
@@ -2,7 +2,7 @@
<div class="container">
<div class="tool-header">
<router-link to="/" class="back-link"> 返回首页</router-link>
<h2> 高程计算工具</h2>
<h2><Mountain class="icon-lg icon-danger" /> 高程计算工具</h2>
<p>高差坡度坡度角计算</p>
</div>
@@ -99,6 +99,7 @@
<script setup>
import { ref, computed } from 'vue'
import { Mountain } from 'lucide-vue-next'
import {
calculateElevationDifference,
calculateSlope,
+31 -11
View File
@@ -1,13 +1,15 @@
<template>
<div class="container">
<div class="hero">
<h1 class="hero-title">📐 测绘工具箱</h1>
<h1 class="hero-title">
<Triangle class="inline-icon" /> 测绘工具箱
</h1>
<p class="hero-subtitle">专业的测绘计算工具集助力您的测绘工作</p>
</div>
<div class="tools-grid">
<router-link v-for="tool in tools" :key="tool.path" :to="tool.path" class="tool-card">
<div class="tool-icon">{{ tool.icon }}</div>
<component :is="tool.icon" class="tool-icon" :class="tool.iconClass" />
<h3 class="tool-title">{{ tool.name }}</h3>
<p class="tool-description">{{ tool.description }}</p>
</router-link>
@@ -17,59 +19,69 @@
<script setup>
import { ref } from 'vue'
import { Triangle, Globe, Ruler, BarChart3, Compass, Mountain, Map, MapPin } from 'lucide-vue-next'
const tools = ref([
{
name: '坐标转换',
icon: '🌍',
icon: Globe,
iconClass: 'icon-primary',
description: '经纬度格式转换,支持度分秒与十进制互转',
path: '/coordinate-converter'
},
{
name: '距离计算',
icon: '📏',
icon: Ruler,
iconClass: 'icon-secondary',
description: '根据两点坐标计算距离,支持经纬度和平面坐标',
path: '/distance-calculator'
},
{
name: '面积计算',
icon: '📐',
icon: Triangle,
iconClass: 'icon-accent',
description: '多边形面积计算,支持任意多边形',
path: '/area-calculator'
},
{
name: '角度转换',
icon: '📊',
icon: BarChart3,
iconClass: 'icon-success',
description: '度分秒、十进制度数、弧度之间的转换',
path: '/angle-converter'
},
{
name: '方位角计算',
icon: '🧭',
icon: Compass,
iconClass: 'icon-warning',
description: '根据两点坐标计算方位角和象限角',
path: '/bearing-calculator'
},
{
name: '高程计算',
icon: '⛰️',
icon: Mountain,
iconClass: 'icon-danger',
description: '高差、坡度、坡度角计算',
path: '/elevation-calculator'
},
{
name: '高德地名搜索',
icon: '🗺️',
icon: Map,
iconClass: 'icon-gradient',
description: '通过高德API搜索POI,获取坐标并导出',
path: '/amap-search'
},
{
name: '图上量测与拾取',
icon: '🗺️',
icon: Map,
iconClass: 'icon-primary',
description: '在地图上点选坐标、量测距离和面积,实时显示地理与投影坐标',
path: '/map-interaction'
},
{
name: '坐标展点',
icon: '📍',
icon: MapPin,
iconClass: 'icon-secondary',
description: '将坐标批量展绘到地图上,支持手动输入、批量粘贴和Excel导入',
path: '/coordinate-plotter'
}
@@ -93,6 +105,14 @@ const tools = ref([
animation: fadeIn 0.6s ease;
}
.inline-icon {
width: 1em;
height: 1em;
display: inline-block;
vertical-align: text-bottom;
color: var(--primary);
}
.hero-subtitle {
font-size: var(--font-size-lg);
color: var(--text-secondary);
@@ -2,7 +2,7 @@
<div class="container map-tool-container">
<div class="tool-header">
<router-link to="/" class="back-link"> 返回首页</router-link>
<h2>🗺 图上量测与拾取</h2>
<h2><Map class="icon-lg icon-primary" /> 图上量测与拾取</h2>
<p>在地图上点选拾取坐标线选量距面选量面积实时显示地理与投影坐标</p>
</div>
@@ -17,7 +17,7 @@
:class="['btn', 'tool-btn', { active: currentMode === tool.mode }]"
@click="setMode(tool.mode)"
>
<span class="tool-btn-icon">{{ tool.icon }}</span>
<component :is="tool.icon" class="tool-btn-icon" :class="tool.iconClass" />
<span class="tool-btn-label">{{ tool.label }}</span>
</button>
</div>
@@ -37,7 +37,7 @@
</div>
<button class="btn btn-secondary clear-btn" @click="clearAll">
🗑 清除全部
<Trash2 class="icon-sm icon-danger" /> 清除全部
</button>
</div>
@@ -78,10 +78,10 @@
<!-- Results Panel -->
<div v-if="results.length > 0" class="results-panel card">
<div class="results-header">
<h3 class="card-title">📋 拾取与量测结果 ({{ results.length }})</h3>
<h3 class="card-title"><ClipboardList class="icon-md icon-primary" /> 拾取与量测结果 ({{ results.length }})</h3>
<div class="results-actions">
<button class="btn btn-secondary" @click="copyResults">📋 复制</button>
<button class="btn btn-secondary" @click="clearResults">🗑 清空</button>
<button class="btn btn-secondary" @click="copyResults"><Copy class="icon-sm icon-success" /> 复制</button>
<button class="btn btn-secondary" @click="clearResults"><Trash2 class="icon-sm icon-danger" /> 清空</button>
</div>
</div>
@@ -94,7 +94,10 @@
>
<div class="result-item-header">
<span class="result-type-badge" :class="r.type">
{{ r.type === 'point' ? '📍 点' : r.type === 'line' ? '📏 线' : '📐 面' }}
<MapPin v-if="r.type === 'point'" class="icon-xs icon-primary" />
<Ruler v-else-if="r.type === 'line'" class="icon-xs icon-secondary" />
<Triangle v-else class="icon-xs icon-accent" />
{{ r.type === 'point' ? '点' : r.type === 'line' ? '线' : '面' }}
</span>
<span class="result-index">#{{ i + 1 }}</span>
</div>
@@ -154,22 +157,30 @@
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { ref, onMounted, computed, watch } from 'vue'
import { Map, Trash2, ClipboardList, Copy, MapPin, Ruler, Triangle } from 'lucide-vue-next'
import { gcj02ToWgs84 } from '../utils/amap'
import { projectToPlane } from '../utils/proj'
import turfLength from '@turf/length'
import turfArea from '@turf/area'
import { lineString, polygon as turfPolygon } from '@turf/helpers'
import { useLeafletMap } from '../composables/useLeafletMap'
import { useToast } from '../composables/useToast'
// State
const mapRef = ref(null)
const { createMap, L } = useLeafletMap(mapRef, {
center: [30.0, 104.0],
zoom: 5,
doubleClickZoom: false
})
const { message, messageType, showMessage } = useToast(3000)
let map = null
const currentMode = ref('point')
const zoneType = ref(3)
const cursorCoord = ref(null)
const results = ref([])
const message = ref('')
const messageType = ref('success')
// Drawing state
let drawingPoints = []
@@ -178,9 +189,9 @@ let drawingMarkers = []
let tempLine = null
const toolModes = [
{ mode: 'point', icon: '📍', label: '点选拾取' },
{ mode: 'line', icon: '📏', label: '线选量距' },
{ mode: 'polygon', icon: '📐', label: '面选量面' }
{ mode: 'point', icon: MapPin, iconClass: 'icon-primary', label: '点选拾取' },
{ mode: 'line', icon: Ruler, iconClass: 'icon-secondary', label: '线选量距' },
{ mode: 'polygon', icon: Triangle, iconClass: 'icon-accent', label: '面选量面' }
]
const currentModeHint = computed(() => {
@@ -194,36 +205,11 @@ const currentModeHint = computed(() => {
// Map Initialization
onMounted(() => {
initMap()
})
onBeforeUnmount(() => {
if (map) {
map.remove()
map = null
}
})
function initMap() {
map = L.map(mapRef.value, {
center: [30.0, 104.0],
zoom: 5,
zoomControl: true,
doubleClickZoom: false
})
// GCJ-02
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
subdomains: ['1', '2', '3', '4'],
maxZoom: 18,
attribution: '&copy; 高德地图'
}).addTo(map)
// Event listeners
map = createMap()
map.on('mousemove', onMouseMove)
map.on('click', onMapClick)
map.on('dblclick', onMapDoubleClick)
}
})
// Coordinate Conversion
function convertCoord(latlng) {
@@ -342,29 +328,12 @@ function onMapDoubleClick() {
}
function finishLine() {
// Calculate distance using haversine via turf-like approach
let totalDist = 0
for (let i = 1; i < drawingPoints.length; i++) {
totalDist += haversine(
drawingPoints[i - 1][0], drawingPoints[i - 1][1],
drawingPoints[i][0], drawingPoints[i][1]
)
}
// Convert the drawing to WGS-84 coordinates for display
const wgsPoints = drawingPoints.map(p => {
const wgsCoords = drawingPoints.map((p) => {
const [wLng, wLat] = gcj02ToWgs84(p[1], p[0])
return [wLat, wLng]
return [wLng, wLat]
})
// Recalculate with WGS-84 coords
let wgsDist = 0
for (let i = 1; i < wgsPoints.length; i++) {
wgsDist += haversine(
wgsPoints[i - 1][0], wgsPoints[i - 1][1],
wgsPoints[i][0], wgsPoints[i][1]
)
}
const wgsDist = turfLength(lineString(wgsCoords), { units: 'meters' })
const distanceText = wgsDist > 1000
? `${(wgsDist / 1000).toFixed(3)} km`
@@ -416,24 +385,16 @@ function finishPolygon() {
}).addTo(map)
// Convert to WGS-84 for accurate measurement
const wgsPoints = drawingPoints.map(p => {
const wgsCoords = drawingPoints.map((p) => {
const [wLng, wLat] = gcj02ToWgs84(p[1], p[0])
return [wLat, wLng]
return [wLng, wLat]
})
const closedCoords = [...wgsCoords, wgsCoords[0]]
// Calculate area using spherical excess formula
const area = sphericalPolygonArea(wgsPoints)
const areaText = formatArea(area)
const areaValue = turfArea(turfPolygon([closedCoords]))
const areaText = formatArea(areaValue)
// Calculate perimeter
let perimeter = 0
const wgsClosed = [...wgsPoints, wgsPoints[0]]
for (let i = 1; i < wgsClosed.length; i++) {
perimeter += haversine(
wgsClosed[i - 1][0], wgsClosed[i - 1][1],
wgsClosed[i][0], wgsClosed[i][1]
)
}
const perimeter = turfLength(lineString(closedCoords), { units: 'meters' })
const perimeterText = perimeter > 1000
? `${(perimeter / 1000).toFixed(3)} km`
: `${perimeter.toFixed(2)} m`
@@ -450,7 +411,7 @@ function finishPolygon() {
results.value.push({
type: 'polygon',
areaText,
area,
area: areaValue,
perimeterText,
perimeter,
pointCount: drawingPoints.length
@@ -460,34 +421,6 @@ function finishPolygon() {
resetDrawing()
}
// Haversine Distance
function haversine(lat1, lon1, lat2, lon2) {
const R = 6371000
const dLat = (lat2 - lat1) * Math.PI / 180
const dLon = (lon2 - lon1) * Math.PI / 180
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
// Spherical Polygon Area
function sphericalPolygonArea(points) {
const R = 6371000
const toRad = d => d * Math.PI / 180
const n = points.length
if (n < 3) return 0
let total = 0
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const k = (i + 2) % n
total += (toRad(points[k][1]) - toRad(points[i][1])) * Math.sin(toRad(points[j][0]))
}
return Math.abs(total * R * R / 2)
}
// Format Area
function formatArea(sqm) {
if (sqm > 1e6) {
@@ -566,13 +499,6 @@ function copyResults() {
})
}
// Messages
function showMessage(msg, isError = false) {
message.value = msg
messageType.value = isError ? 'error' : 'success'
setTimeout(() => { message.value = '' }, 3000)
}
// Watch zone type change
watch(zoneType, () => {
if (cursorCoord.value) {
+12
View File
@@ -11,4 +11,16 @@ export default defineConfig({
open: false, // 启动时不自动打开浏览器
},
base: '/geo-tools/',
build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router'],
'geo-libs': ['@turf/area', '@turf/length', '@turf/helpers', 'proj4', 'gcoord'],
'ui-libs': ['lucide-vue-next', 'leaflet'],
'exceljs': ['exceljs']
}
}
}
}
})
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'node',
include: ['src/**/*.{test,spec}.{js,mjs}']
}
})