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:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"endOfLine": "auto",
|
||||
"vueIndentScriptAndStyle": false
|
||||
}
|
||||
@@ -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 子包(面积/长度)
|
||||
- ExcelJS(Excel 读写)
|
||||
- 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 / 按项目需要自行约定。
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
Generated
+2401
-2243
File diff suppressed because it is too large
Load Diff
+19
-5
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '© 高德地图'
|
||||
}).addTo(map)
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
function destroyMap() {
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
}
|
||||
|
||||
function getMap() {
|
||||
return map
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroyMap()
|
||||
})
|
||||
|
||||
return {
|
||||
createMap,
|
||||
destroyMap,
|
||||
getMap,
|
||||
L
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
]
|
||||
}
|
||||
|
||||
+17
-6
@@ -19,16 +19,27 @@ export function dmsToDecimal(degrees, minutes, seconds) {
|
||||
* @returns {{degrees: number, minutes: number, seconds: number}}
|
||||
*/
|
||||
export function decimalToDms(decimal) {
|
||||
const isNegative = decimal < 0;
|
||||
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;
|
||||
let degrees = Math.floor(absDecimal);
|
||||
let minutesDecimal = (absDecimal - degrees) * 60;
|
||||
let minutes = Math.floor(minutesDecimal);
|
||||
let seconds = (minutesDecimal - minutes) * 60;
|
||||
|
||||
seconds = parseFloat(seconds.toFixed(6));
|
||||
if (seconds >= 60) {
|
||||
seconds = 0;
|
||||
minutes += 1;
|
||||
}
|
||||
if (minutes >= 60) {
|
||||
minutes = 0;
|
||||
degrees += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
degrees: decimal < 0 ? -degrees : degrees,
|
||||
degrees: isNegative ? -degrees : degrees,
|
||||
minutes,
|
||||
seconds: parseFloat(seconds.toFixed(6))
|
||||
seconds
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,24 +395,17 @@ 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) => {
|
||||
file.arrayBuffer()
|
||||
.then(async (arrayBuffer) => {
|
||||
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)
|
||||
const { rows: json } = await readExcelAsJson(arrayBuffer)
|
||||
if (json.length === 0) return alert('Excel无数据')
|
||||
|
||||
// 识别是哪种模式 (通过列名)
|
||||
@@ -520,10 +513,7 @@ const handleFileUpload = (e) => {
|
||||
}
|
||||
}
|
||||
|
||||
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`)
|
||||
await writeJsonToExcel(resultData, `坐标转换结果_${Date.now()}.xlsx`, '转换结果')
|
||||
|
||||
// clear input
|
||||
e.target.value = ''
|
||||
@@ -531,8 +521,10 @@ const handleFileUpload = (e) => {
|
||||
} catch (err) {
|
||||
alert('文件解析失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
.catch((err) => {
|
||||
alert('文件解析失败: ' + err.message)
|
||||
})
|
||||
}
|
||||
</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: '© 高德地图'
|
||||
}).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) => {
|
||||
file.arrayBuffer()
|
||||
.then(async (arrayBuffer) => {
|
||||
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)
|
||||
const { rows } = await readExcelAsJson(arrayBuffer)
|
||||
|
||||
if (json.length === 0) {
|
||||
if (rows.length === 0) {
|
||||
showMsg('Excel 文件为空', true)
|
||||
return
|
||||
}
|
||||
|
||||
excelRawData.value = json
|
||||
excelColumns.value = Object.keys(json[0])
|
||||
// Auto-detect column mapping
|
||||
excelRawData.value = rows
|
||||
excelColumns.value = Object.keys(rows[0])
|
||||
autoDetectColumns()
|
||||
showMsg(`已读取 ${json.length} 行数据,请设置列映射`)
|
||||
showMsg(`已读取 ${rows.length} 行数据,请设置列映射`)
|
||||
} catch (err) {
|
||||
showMsg(`读取文件失败: ${err.message}`, true)
|
||||
}
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
.catch((err) => {
|
||||
showMsg(`读取文件失败: ${err.message}`, true)
|
||||
})
|
||||
}
|
||||
|
||||
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
@@ -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: '© 高德地图'
|
||||
}).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) {
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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}']
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user