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
+2403
-2245
File diff suppressed because it is too large
Load Diff
+19
-5
@@ -6,20 +6,34 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"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": {
|
"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",
|
"gcoord": "^1.0.7",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-vue-next": "^0.563.0",
|
"lucide-vue-next": "^0.563.0",
|
||||||
"proj4": "^2.20.4",
|
"proj4": "^2.20.4",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4"
|
||||||
"xlsx": "^0.18.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@vitejs/plugin-vue": "^6.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="container">
|
||||||
<div class="nav-content">
|
<div class="nav-content">
|
||||||
<router-link to="/" class="logo">
|
<router-link to="/" class="logo">
|
||||||
<span class="logo-icon">📐</span>
|
<Triangle class="logo-icon icon-gradient" />
|
||||||
<span class="logo-text">测绘工具箱</span>
|
<span class="logo-text">测绘工具箱</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<button @click="toggleTheme" class="theme-toggle" aria-label="切换主题">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Triangle, Sun, Moon } from 'lucide-vue-next'
|
||||||
|
|
||||||
const isDark = ref(false)
|
const isDark = ref(false)
|
||||||
|
|
||||||
@@ -44,10 +46,12 @@ const toggleTheme = () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const savedTheme = localStorage.getItem('theme')
|
const savedTheme = localStorage.getItem('theme')
|
||||||
isDark.value = savedTheme === 'dark'
|
|
||||||
if (savedTheme) {
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -322,6 +322,63 @@ p {
|
|||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
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 */
|
/* Utility Classes */
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: 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 { 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 = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
component: HomeView
|
component: () => import('./views/HomeView.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/coordinate-converter',
|
path: '/coordinate-converter',
|
||||||
name: 'CoordinateConverter',
|
name: 'CoordinateConverter',
|
||||||
component: CoordinateConverter
|
component: () => import('./views/CoordinateConverter.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/distance-calculator',
|
path: '/distance-calculator',
|
||||||
name: 'DistanceCalculator',
|
name: 'DistanceCalculator',
|
||||||
component: DistanceCalculator
|
component: () => import('./views/DistanceCalculator.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/area-calculator',
|
path: '/area-calculator',
|
||||||
name: 'AreaCalculator',
|
name: 'AreaCalculator',
|
||||||
component: AreaCalculator
|
component: () => import('./views/AreaCalculator.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/angle-converter',
|
path: '/angle-converter',
|
||||||
name: 'AngleConverter',
|
name: 'AngleConverter',
|
||||||
component: AngleConverter
|
component: () => import('./views/AngleConverter.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/bearing-calculator',
|
path: '/bearing-calculator',
|
||||||
name: 'BearingCalculator',
|
name: 'BearingCalculator',
|
||||||
component: BearingCalculator
|
component: () => import('./views/BearingCalculator.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/elevation-calculator',
|
path: '/elevation-calculator',
|
||||||
name: 'ElevationCalculator',
|
name: 'ElevationCalculator',
|
||||||
component: ElevationCalculator
|
component: () => import('./views/ElevationCalculator.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/amap-search',
|
path: '/amap-search',
|
||||||
name: 'AmapSearch',
|
name: 'AmapSearch',
|
||||||
component: AmapSearchTool
|
component: () => import('./views/AmapSearchTool.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/map-interaction',
|
path: '/map-interaction',
|
||||||
name: 'MapInteraction',
|
name: 'MapInteraction',
|
||||||
component: MapInteraction
|
component: () => import('./views/MapInteraction.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/coordinate-plotter',
|
path: '/coordinate-plotter',
|
||||||
name: 'CoordinatePlotter',
|
name: 'CoordinatePlotter',
|
||||||
component: CoordinatePlotter
|
component: () => import('./views/CoordinatePlotter.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+9
-66
@@ -1,72 +1,15 @@
|
|||||||
/**
|
import gcoord from 'gcoord'
|
||||||
* 高德地图坐标转换工具函数
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GCJ-02 转 WGS-84
|
* GCJ-02 转 WGS-84
|
||||||
* @param {number} lng - GCJ-02经度
|
* @param {number} lng - GCJ-02 经度
|
||||||
* @param {number} lat - GCJ-02纬度
|
* @param {number} lat - GCJ-02 纬度
|
||||||
* @returns {Array} [WGS-84经度, WGS-84纬度]
|
* @returns {[number, number]} [WGS-84 经度, WGS-84 纬度]
|
||||||
*/
|
*/
|
||||||
export function gcj02ToWgs84(lng, lat) {
|
export function gcj02ToWgs84(lng, lat) {
|
||||||
// 检查是否在国内
|
const [wgsLng, wgsLat] = gcoord.transform([lng, lat], gcoord.GCJ02, gcoord.WGS84)
|
||||||
if (outOfChina(lng, lat)) {
|
return [
|
||||||
return [lng, lat];
|
parseFloat(Number(wgsLng).toFixed(6)),
|
||||||
}
|
parseFloat(Number(wgsLat).toFixed(6))
|
||||||
|
]
|
||||||
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))];
|
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-27
@@ -10,7 +10,7 @@
|
|||||||
* @returns {number} 十进制度数
|
* @returns {number} 十进制度数
|
||||||
*/
|
*/
|
||||||
export function dmsToDecimal(degrees, minutes, seconds) {
|
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}}
|
* @returns {{degrees: number, minutes: number, seconds: number}}
|
||||||
*/
|
*/
|
||||||
export function decimalToDms(decimal) {
|
export function decimalToDms(decimal) {
|
||||||
const absDecimal = Math.abs(decimal);
|
const isNegative = decimal < 0;
|
||||||
const degrees = Math.floor(absDecimal);
|
const absDecimal = Math.abs(decimal);
|
||||||
const minutesDecimal = (absDecimal - degrees) * 60;
|
let degrees = Math.floor(absDecimal);
|
||||||
const minutes = Math.floor(minutesDecimal);
|
let minutesDecimal = (absDecimal - degrees) * 60;
|
||||||
const seconds = (minutesDecimal - minutes) * 60;
|
let minutes = Math.floor(minutesDecimal);
|
||||||
|
let seconds = (minutesDecimal - minutes) * 60;
|
||||||
|
|
||||||
return {
|
seconds = parseFloat(seconds.toFixed(6));
|
||||||
degrees: decimal < 0 ? -degrees : degrees,
|
if (seconds >= 60) {
|
||||||
minutes,
|
seconds = 0;
|
||||||
seconds: parseFloat(seconds.toFixed(6))
|
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} 距离(米)
|
* @returns {number} 距离(米)
|
||||||
*/
|
*/
|
||||||
export function haversineDistance(lat1, lon1, lat2, lon2) {
|
export function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||||
const R = 6371000; // 地球半径(米)
|
const R = 6371000; // 地球半径(米)
|
||||||
const φ1 = lat1 * Math.PI / 180;
|
const φ1 = lat1 * Math.PI / 180;
|
||||||
const φ2 = lat2 * Math.PI / 180;
|
const φ2 = lat2 * Math.PI / 180;
|
||||||
const Δφ = (lat2 - lat1) * Math.PI / 180;
|
const Δφ = (lat2 - lat1) * Math.PI / 180;
|
||||||
const Δλ = (lon2 - lon1) * Math.PI / 180;
|
const Δλ = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
|
||||||
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||||
Math.cos(φ1) * Math.cos(φ2) *
|
Math.cos(φ1) * Math.cos(φ2) *
|
||||||
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
return R * c;
|
return R * c;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,7 +75,7 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
|
|||||||
* @returns {number} 距离
|
* @returns {number} 距离
|
||||||
*/
|
*/
|
||||||
export function planarDistance(x1, y1, x2, y2) {
|
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} 格式化的坐标字符串
|
* @returns {string} 格式化的坐标字符串
|
||||||
*/
|
*/
|
||||||
export function formatCoordinate(decimal, type) {
|
export function formatCoordinate(decimal, type) {
|
||||||
const dms = decimalToDms(decimal);
|
const dms = decimalToDms(decimal);
|
||||||
const direction = type === 'lat'
|
const direction = type === 'lat'
|
||||||
? (decimal >= 0 ? 'N' : 'S')
|
? (decimal >= 0 ? 'N' : 'S')
|
||||||
: (decimal >= 0 ? 'E' : 'W');
|
: (decimal >= 0 ? 'E' : 'W');
|
||||||
|
|
||||||
return `${Math.abs(dms.degrees)}°${dms.minutes}'${dms.seconds.toFixed(4)}" ${direction}`;
|
return `${Math.abs(dms.degrees)}°${dms.minutes}'${dms.seconds.toFixed(4)}" ${direction}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
if (azimuth < 0) azimuth += 360;
|
||||||
|
|
||||||
// 计算象限角
|
// 计算象限角
|
||||||
let quadrant = '';
|
|
||||||
const angle = Math.abs(Math.atan2(dx, dy) * 180 / Math.PI);
|
const angle = Math.abs(Math.atan2(dx, dy) * 180 / Math.PI);
|
||||||
|
let quadrant;
|
||||||
if (dx >= 0 && dy >= 0) {
|
if (dx >= 0 && dy >= 0) {
|
||||||
quadrant = `N ${angle.toFixed(2)}° E`;
|
quadrant = `N ${angle.toFixed(2)}° E`;
|
||||||
} else if (dx >= 0 && dy < 0) {
|
} else if (dx >= 0 && dy < 0) {
|
||||||
@@ -51,44 +50,3 @@ export function calculateBearing(x1, y1, x2, y2) {
|
|||||||
quadrant
|
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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { gcj02ToWgs84 } from '../utils/amap'
|
import { gcj02ToWgs84 } from '../utils/amap'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
const apiKey = ref('')
|
const apiKey = ref('')
|
||||||
const keyword = ref('人民政府')
|
const keyword = ref('人民政府')
|
||||||
@@ -155,23 +156,13 @@ const city = ref('楚雄')
|
|||||||
const pageSize = ref(40)
|
const pageSize = ref(40)
|
||||||
const searchResults = ref([])
|
const searchResults = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const { message, messageType, showMessage } = useToast(5000)
|
||||||
const successMessage = ref('')
|
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 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密钥
|
// 保存API密钥
|
||||||
const saveApiKey = () => {
|
const saveApiKey = () => {
|
||||||
const trimmedKey = apiKey.value.trim()
|
const trimmedKey = apiKey.value.trim()
|
||||||
@@ -287,8 +278,7 @@ const performSearch = async () => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
searchResults.value = []
|
searchResults.value = []
|
||||||
errorMessage.value = ''
|
message.value = ''
|
||||||
successMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = []
|
const results = []
|
||||||
@@ -82,26 +82,26 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
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 dms = ref({ degrees: 45, minutes: 30, seconds: 0 })
|
||||||
const decimal = ref(45.5)
|
const decimal = ref(45.5)
|
||||||
const radian = ref(0.7941)
|
const radian = ref(0.7941)
|
||||||
|
|
||||||
const fromDms = () => {
|
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
|
radian.value = decimal.value * Math.PI / 180
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromDecimal = () => {
|
const fromDecimal = () => {
|
||||||
const converted = decimalAngleToDms(decimal.value)
|
const converted = decimalToDms(decimal.value)
|
||||||
dms.value = converted
|
dms.value = converted
|
||||||
radian.value = decimal.value * Math.PI / 180
|
radian.value = decimal.value * Math.PI / 180
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromRadian = () => {
|
const fromRadian = () => {
|
||||||
decimal.value = radian.value * 180 / Math.PI
|
decimal.value = radian.value * 180 / Math.PI
|
||||||
const converted = decimalAngleToDms(decimal.value)
|
const converted = decimalToDms(decimal.value)
|
||||||
dms.value = converted
|
dms.value = converted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
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'
|
import { ChevronLeft, SquareStack, LayoutList, Trash2, Activity, Info } from 'lucide-vue-next'
|
||||||
|
|
||||||
const points = ref([
|
const points = ref([
|
||||||
@@ -175,9 +177,9 @@ const calculate = () => {
|
|||||||
if (coordType.value === 'lnglat' && calcMethod.value === 'geodetic') {
|
if (coordType.value === 'lnglat' && calcMethod.value === 'geodetic') {
|
||||||
const coords = rawPoints.map(p => [p.lng, p.lat])
|
const coords = rawPoints.map(p => [p.lng, p.lat])
|
||||||
coords.push(coords[0])
|
coords.push(coords[0])
|
||||||
const polygon = turf.polygon([coords])
|
const poly = turfPolygon([coords])
|
||||||
area.value = turf.area(polygon)
|
area.value = turfArea(poly)
|
||||||
perimeter.value = turf.length(polygon, { units: 'meters' })
|
perimeter.value = turfLength(poly, { units: 'meters' })
|
||||||
} else {
|
} else {
|
||||||
let pts = [];
|
let pts = [];
|
||||||
if (coordType.value === 'planar') {
|
if (coordType.value === 'planar') {
|
||||||
@@ -245,7 +245,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import gcoord from 'gcoord'
|
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 { ChevronLeft, Globe, RefreshCw, MapPin, Copy, CheckCircle2, Layers, FileSpreadsheet, DownloadCloud, UploadCloud } from 'lucide-vue-next'
|
||||||
import { dmsToDecimal, formatCoordinate } from '../utils/coordinate'
|
import { dmsToDecimal, formatCoordinate } from '../utils/coordinate'
|
||||||
import { projectToPlane, unprojectToLngLat } from '../utils/proj'
|
import { projectToPlane, unprojectToLngLat } from '../utils/proj'
|
||||||
@@ -358,7 +358,7 @@ const doProjection = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copyResult = () => {
|
const copyResult = () => {
|
||||||
let text = ''
|
let text
|
||||||
if (result.value.type === 'decimal') {
|
if (result.value.type === 'decimal') {
|
||||||
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
|
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
|
||||||
} else if (result.value.type === 'dms') {
|
} else if (result.value.type === 'dms') {
|
||||||
@@ -378,7 +378,7 @@ const copyResult = () => {
|
|||||||
|
|
||||||
// === Bulk Excel Conversion ===
|
// === Bulk Excel Conversion ===
|
||||||
|
|
||||||
const downloadTemplate = (type) => {
|
const downloadTemplate = async (type) => {
|
||||||
let wsData = []
|
let wsData = []
|
||||||
let filename = ''
|
let filename = ''
|
||||||
if (type === 'toPlane') {
|
if (type === 'toPlane') {
|
||||||
@@ -394,145 +394,137 @@ const downloadTemplate = (type) => {
|
|||||||
wsData = [['点名', '纬度(十进制)', '经度(十进制)'], ['pt1', 39.9075, 116.3972]]
|
wsData = [['点名', '纬度(十进制)', '经度(十进制)'], ['pt1', 39.9075, 116.3972]]
|
||||||
filename = '十进制转度分秒模板.xlsx'
|
filename = '十进制转度分秒模板.xlsx'
|
||||||
}
|
}
|
||||||
|
|
||||||
const ws = xlsx.utils.aoa_to_sheet(wsData)
|
await writeAoaToExcel(wsData, filename, 'Sheet1')
|
||||||
const wb = xlsx.utils.book_new()
|
|
||||||
xlsx.utils.book_append_sheet(wb, ws, "Sheet1")
|
|
||||||
xlsx.writeFile(wb, filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileUpload = (e) => {
|
const handleFileUpload = (e) => {
|
||||||
const file = e.target.files[0]
|
const file = e.target.files[0]
|
||||||
if (!file) return
|
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()
|
||||||
const first = json[0]
|
.then(async (arrayBuffer) => {
|
||||||
const isToDecimal = '纬度度' in first
|
try {
|
||||||
const isToDms = '纬度(十进制)' in first || '经度(十进制)' in first
|
const { rows: json } = await readExcelAsJson(arrayBuffer)
|
||||||
const isToPlane = !isToDecimal && !isToDms && ('经度' in first || '经度(Lon)' in first)
|
if (json.length === 0) return alert('Excel无数据')
|
||||||
const isToLngLat = 'X坐标(北移)' in first || 'X坐标' in first
|
|
||||||
|
|
||||||
const resultData = []
|
// 识别是哪种模式 (通过列名)
|
||||||
|
const first = json[0]
|
||||||
|
const isToDecimal = '纬度度' in first
|
||||||
|
const isToDms = '纬度(十进制)' in first || '经度(十进制)' in first
|
||||||
|
const isToPlane = !isToDecimal && !isToDms && ('经度' in first || '经度(Lon)' in first)
|
||||||
|
const isToLngLat = 'X坐标(北移)' in first || 'X坐标' in first
|
||||||
|
|
||||||
|
const resultData = []
|
||||||
|
|
||||||
|
for (let i = 0; i < json.length; i++) {
|
||||||
|
const row = json[i]
|
||||||
|
const name = row['点名'] || `Point${i+1}`
|
||||||
|
const zoneType = parseInt(row['分带类型(3/6)'] || projConfig.value.zoneType)
|
||||||
|
|
||||||
|
if (isToDecimal) {
|
||||||
|
const latDeg = parseFloat(row['纬度度']) || 0
|
||||||
|
const latMin = parseFloat(row['纬度分']) || 0
|
||||||
|
const latSec = parseFloat(row['纬度秒']) || 0
|
||||||
|
const latDir = row['纬度方向(N/S)'] || 'N'
|
||||||
|
|
||||||
|
const lonDeg = parseFloat(row['经度度']) || 0
|
||||||
|
const lonMin = parseFloat(row['经度分']) || 0
|
||||||
|
const lonSec = parseFloat(row['经度秒']) || 0
|
||||||
|
const lonDir = row['经度方向(E/W)'] || 'E'
|
||||||
|
|
||||||
for (let i = 0; i < json.length; i++) {
|
|
||||||
const row = json[i]
|
|
||||||
const name = row['点名'] || `Point${i+1}`
|
|
||||||
const zoneType = parseInt(row['分带类型(3/6)'] || projConfig.value.zoneType)
|
|
||||||
|
|
||||||
if (isToDecimal) {
|
|
||||||
const latDeg = parseFloat(row['纬度度']) || 0
|
|
||||||
const latMin = parseFloat(row['纬度分']) || 0
|
|
||||||
const latSec = parseFloat(row['纬度秒']) || 0
|
|
||||||
const latDir = row['纬度方向(N/S)'] || 'N'
|
|
||||||
|
|
||||||
const lonDeg = parseFloat(row['经度度']) || 0
|
|
||||||
const lonMin = parseFloat(row['经度分']) || 0
|
|
||||||
const lonSec = parseFloat(row['经度秒']) || 0
|
|
||||||
const lonDir = row['经度方向(E/W)'] || 'E'
|
|
||||||
|
|
||||||
try {
|
|
||||||
let latDec = dmsToDecimal(latDeg, latMin, latSec)
|
|
||||||
if (latDir.toUpperCase() === 'S') latDec = -latDec
|
|
||||||
let lonDec = dmsToDecimal(lonDeg, lonMin, lonSec)
|
|
||||||
if (lonDir.toUpperCase() === 'W') lonDec = -lonDec
|
|
||||||
|
|
||||||
resultData.push({
|
|
||||||
...row,
|
|
||||||
'点名': name,
|
|
||||||
'纬度(十进制)': latDec.toFixed(8),
|
|
||||||
'经度(十进制)': lonDec.toFixed(8)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
resultData.push({ ...row, '点名': name, '错误': err.message })
|
|
||||||
}
|
|
||||||
} else if (isToDms) {
|
|
||||||
const latDec = parseFloat(row['纬度(十进制)'])
|
|
||||||
const lonDec = parseFloat(row['经度(十进制)'])
|
|
||||||
|
|
||||||
if (!isNaN(latDec) && !isNaN(lonDec)) {
|
|
||||||
try {
|
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({
|
resultData.push({
|
||||||
...row,
|
...row,
|
||||||
'点名': name,
|
'点名': name,
|
||||||
'纬度(度分秒)': formatCoordinate(latDec, 'lat'),
|
'纬度(十进制)': latDec.toFixed(8),
|
||||||
'经度(度分秒)': formatCoordinate(lonDec, 'lon')
|
'经度(十进制)': lonDec.toFixed(8)
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
resultData.push({ ...row, '点名': name, '错误': err.message })
|
resultData.push({ ...row, '点名': name, '错误': err.message })
|
||||||
}
|
}
|
||||||
}
|
} else if (isToDms) {
|
||||||
} else if (isToPlane) {
|
const latDec = parseFloat(row['纬度(十进制)'])
|
||||||
const lng = parseFloat(row['经度']) || parseFloat(row['经度(Lon)'])
|
const lonDec = parseFloat(row['经度(十进制)'])
|
||||||
const lat = parseFloat(row['纬度']) || parseFloat(row['纬度(Lat)'])
|
|
||||||
const withZone = (row['X包含带号(1/0)'] !== undefined) ? (parseInt(row['X包含带号(1/0)']) === 1) : true
|
if (!isNaN(latDec) && !isNaN(lonDec)) {
|
||||||
|
try {
|
||||||
if (lng && lat) {
|
resultData.push({
|
||||||
try {
|
...row,
|
||||||
const res = projectToPlane(lng, lat, zoneType, withZone)
|
'点名': name,
|
||||||
resultData.push({
|
'纬度(度分秒)': formatCoordinate(latDec, 'lat'),
|
||||||
...row,
|
'经度(度分秒)': formatCoordinate(lonDec, 'lon')
|
||||||
'点名': name,
|
})
|
||||||
'经度': lng,
|
} catch (err) {
|
||||||
'纬度': lat,
|
resultData.push({ ...row, '点名': name, '错误': err.message })
|
||||||
'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 (isToPlane) {
|
||||||
} else if (isToLngLat) {
|
const lng = parseFloat(row['经度']) || parseFloat(row['经度(Lon)'])
|
||||||
const northing = parseFloat(row['X坐标(北移)']) || parseFloat(row['X坐标'])
|
const lat = parseFloat(row['纬度']) || parseFloat(row['纬度(Lat)'])
|
||||||
const easting = parseFloat(row['Y坐标(东移)']) || parseFloat(row['Y坐标'])
|
const withZone = (row['X包含带号(1/0)'] !== undefined) ? (parseInt(row['X包含带号(1/0)']) === 1) : true
|
||||||
const manualZone = row['手动带号'] ? parseInt(row['手动带号']) : null
|
|
||||||
|
if (lng && lat) {
|
||||||
const withZone = easting > 1000000
|
try {
|
||||||
|
const res = projectToPlane(lng, lat, zoneType, withZone)
|
||||||
if (northing && easting) {
|
resultData.push({
|
||||||
try {
|
...row,
|
||||||
const res = unprojectToLngLat(easting, northing, zoneType, withZone, manualZone)
|
'点名': name,
|
||||||
resultData.push({
|
'经度': lng,
|
||||||
...row,
|
'纬度': lat,
|
||||||
'点名': name,
|
'X坐标(北移/Northing)': res.northing.toFixed(4),
|
||||||
'X坐标(北)': northing,
|
'Y坐标(东移/Easting)': res.easting.toFixed(4),
|
||||||
'Y坐标(东)': easting,
|
'带号': res.zone,
|
||||||
'经度': res.lng.toFixed(8),
|
'中央子午线': res.meridian
|
||||||
'纬度': res.lat.toFixed(8),
|
})
|
||||||
'带号': res.zone,
|
} catch (err) {
|
||||||
'中央子午线': res.meridian
|
resultData.push({ ...row, '点名': name, '错误': err.message })
|
||||||
})
|
}
|
||||||
} catch (err) {
|
}
|
||||||
resultData.push({ ...row, '点名': name, '错误': err.message })
|
} else if (isToLngLat) {
|
||||||
|
const northing = parseFloat(row['X坐标(北移)']) || parseFloat(row['X坐标'])
|
||||||
|
const easting = parseFloat(row['Y坐标(东移)']) || parseFloat(row['Y坐标'])
|
||||||
|
const manualZone = row['手动带号'] ? parseInt(row['手动带号']) : null
|
||||||
|
|
||||||
|
const withZone = easting > 1000000
|
||||||
|
|
||||||
|
if (northing && easting) {
|
||||||
|
try {
|
||||||
|
const res = unprojectToLngLat(easting, northing, zoneType, withZone, manualZone)
|
||||||
|
resultData.push({
|
||||||
|
...row,
|
||||||
|
'点名': name,
|
||||||
|
'X坐标(北)': northing,
|
||||||
|
'Y坐标(东)': easting,
|
||||||
|
'经度': res.lng.toFixed(8),
|
||||||
|
'纬度': res.lat.toFixed(8),
|
||||||
|
'带号': res.zone,
|
||||||
|
'中央子午线': res.meridian
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
resultData.push({ ...row, '点名': name, '错误': err.message })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await writeJsonToExcel(resultData, `坐标转换结果_${Date.now()}.xlsx`, '转换结果')
|
||||||
|
|
||||||
|
// clear input
|
||||||
|
e.target.value = ''
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
alert('文件解析失败: ' + err.message)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
const newWs = xlsx.utils.json_to_sheet(resultData)
|
.catch((err) => {
|
||||||
const newWb = xlsx.utils.book_new()
|
|
||||||
xlsx.utils.book_append_sheet(newWb, newWs, "转换结果")
|
|
||||||
xlsx.writeFile(newWb, `坐标转换结果_${Date.now()}.xlsx`)
|
|
||||||
|
|
||||||
// clear input
|
|
||||||
e.target.value = ''
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
alert('文件解析失败: ' + err.message)
|
alert('文件解析失败: ' + err.message)
|
||||||
}
|
})
|
||||||
}
|
|
||||||
reader.readAsArrayBuffer(file)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="container plotter-container">
|
<div class="container plotter-container">
|
||||||
<div class="tool-header">
|
<div class="tool-header">
|
||||||
<router-link to="/" class="back-link">← 返回首页</router-link>
|
<router-link to="/" class="back-link">← 返回首页</router-link>
|
||||||
<h2>📍 坐标展点</h2>
|
<h2><MapPin class="icon-lg icon-primary" /> 坐标展点</h2>
|
||||||
<p>将输入或计算得到的坐标批量展绘到地图上,直观展示分布</p>
|
<p>将输入或计算得到的坐标批量展绘到地图上,直观展示分布</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
:class="['tab-btn', { active: activeTab === tab.key }]"
|
:class="['tab-btn', { active: activeTab === tab.key }]"
|
||||||
@click="activeTab = tab.key"
|
@click="activeTab = tab.key"
|
||||||
>
|
>
|
||||||
{{ tab.icon }} {{ tab.label }}
|
<component :is="tab.icon" class="icon-sm" :class="tab.iconClass" /> {{ tab.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,11 +147,11 @@
|
|||||||
<!-- Point List -->
|
<!-- Point List -->
|
||||||
<div v-if="points.length > 0" class="points-panel card">
|
<div v-if="points.length > 0" class="points-panel card">
|
||||||
<div class="points-header">
|
<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">
|
<div class="points-actions">
|
||||||
<button class="btn btn-secondary btn-sm" @click="fitMapToPoints">🔍 全部定位</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">📤 导出Excel</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">🗑️ 清空</button>
|
<button class="btn btn-secondary btn-sm" @click="clearPoints"><Trash2 class="icon-sm icon-danger" /> 清空</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="points-table-wrapper">
|
<div class="points-table-wrapper">
|
||||||
@@ -190,16 +190,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, computed, nextTick } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import L from 'leaflet'
|
import { MapPin, List, Search, Download, Trash2, PenTool, FileText, Table } from 'lucide-vue-next'
|
||||||
import 'leaflet/dist/leaflet.css'
|
|
||||||
import { unprojectToLngLat } from '../utils/proj'
|
import { unprojectToLngLat } from '../utils/proj'
|
||||||
import * as XLSX from 'xlsx'
|
import { readExcelAsJson, writeJsonToExcel } from '../utils/excel'
|
||||||
import gcoord from 'gcoord'
|
import gcoord from 'gcoord'
|
||||||
|
import { useLeafletMap } from '../composables/useLeafletMap'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
// ─── State ────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────
|
||||||
const mapRef = ref(null)
|
const mapRef = ref(null)
|
||||||
const fileInput = 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 map = null
|
||||||
let markerGroup = null
|
let markerGroup = null
|
||||||
|
|
||||||
@@ -227,14 +233,10 @@ const mappingName = ref('')
|
|||||||
// Points data (always stored as WGS-84 lng/lat)
|
// Points data (always stored as WGS-84 lng/lat)
|
||||||
const points = ref([])
|
const points = ref([])
|
||||||
|
|
||||||
// Messages
|
|
||||||
const message = ref('')
|
|
||||||
const msgType = ref('success')
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'manual', icon: '✏️', label: '手动输入' },
|
{ key: 'manual', icon: PenTool, iconClass: 'icon-primary', label: '手动输入' },
|
||||||
{ key: 'batch', icon: '📝', label: '批量粘贴' },
|
{ key: 'batch', icon: FileText, iconClass: 'icon-secondary', label: '批量粘贴' },
|
||||||
{ key: 'excel', icon: '📊', label: 'Excel导入' }
|
{ key: 'excel', icon: Table, iconClass: 'icon-accent', label: 'Excel导入' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const batchPlaceholder = computed(() => {
|
const batchPlaceholder = computed(() => {
|
||||||
@@ -245,31 +247,9 @@ const batchPlaceholder = computed(() => {
|
|||||||
|
|
||||||
// ─── Map ──────────────────────────────────────────────
|
// ─── Map ──────────────────────────────────────────────
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initMap()
|
map = createMap()
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
markerGroup = L.layerGroup().addTo(map)
|
markerGroup = L.layerGroup().addTo(map)
|
||||||
}
|
})
|
||||||
|
|
||||||
// ─── Convert coordinate to WGS-84 lng/lat ────────────
|
// ─── Convert coordinate to WGS-84 lng/lat ────────────
|
||||||
function toWgs84(x, y) {
|
function toWgs84(x, y) {
|
||||||
@@ -284,7 +264,7 @@ function toWgs84(x, y) {
|
|||||||
)
|
)
|
||||||
return { lng: result.lng, lat: result.lat }
|
return { lng: result.lng, lat: result.lat }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`投影反算失败: ${e.message}`)
|
throw new Error(`投影反算失败: ${e.message}`, { cause: e })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,30 +394,27 @@ function handleFileSelect(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readExcelFile(file) {
|
function readExcelFile(file) {
|
||||||
const reader = new FileReader()
|
file.arrayBuffer()
|
||||||
reader.onload = (e) => {
|
.then(async (arrayBuffer) => {
|
||||||
try {
|
try {
|
||||||
const data = new Uint8Array(e.target.result)
|
const { rows } = await readExcelAsJson(arrayBuffer)
|
||||||
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)
|
|
||||||
|
|
||||||
if (json.length === 0) {
|
if (rows.length === 0) {
|
||||||
showMsg('Excel 文件为空', true)
|
showMsg('Excel 文件为空', true)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
excelRawData.value = rows
|
||||||
|
excelColumns.value = Object.keys(rows[0])
|
||||||
|
autoDetectColumns()
|
||||||
|
showMsg(`已读取 ${rows.length} 行数据,请设置列映射`)
|
||||||
|
} catch (err) {
|
||||||
|
showMsg(`读取文件失败: ${err.message}`, true)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
excelRawData.value = json
|
.catch((err) => {
|
||||||
excelColumns.value = Object.keys(json[0])
|
|
||||||
// Auto-detect column mapping
|
|
||||||
autoDetectColumns()
|
|
||||||
showMsg(`已读取 ${json.length} 行数据,请设置列映射`)
|
|
||||||
} catch (err) {
|
|
||||||
showMsg(`读取文件失败: ${err.message}`, true)
|
showMsg(`读取文件失败: ${err.message}`, true)
|
||||||
}
|
})
|
||||||
}
|
|
||||||
reader.readAsArrayBuffer(file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoDetectColumns() {
|
function autoDetectColumns() {
|
||||||
@@ -543,7 +520,7 @@ function refreshMarkers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Export ──────────────────────────────────────────
|
// ─── Export ──────────────────────────────────────────
|
||||||
function exportExcel() {
|
async function exportExcel() {
|
||||||
if (points.value.length === 0) {
|
if (points.value.length === 0) {
|
||||||
showMsg('没有可导出的数据', true)
|
showMsg('没有可导出的数据', true)
|
||||||
return
|
return
|
||||||
@@ -556,19 +533,9 @@ function exportExcel() {
|
|||||||
'WGS-84纬度': p.lat.toFixed(6)
|
'WGS-84纬度': p.lat.toFixed(6)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const ws = XLSX.utils.json_to_sheet(rows)
|
await writeJsonToExcel(rows, `坐标展点_${new Date().toISOString().slice(0, 10)}.xlsx`, '坐标')
|
||||||
const wb = XLSX.utils.book_new()
|
|
||||||
XLSX.utils.book_append_sheet(wb, ws, '坐标')
|
|
||||||
XLSX.writeFile(wb, `坐标展点_${new Date().toISOString().slice(0, 10)}.xlsx`)
|
|
||||||
showMsg(`已导出 ${points.value.length} 个坐标点`)
|
showMsg(`已导出 ${points.value.length} 个坐标点`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Messages ────────────────────────────────────────
|
|
||||||
function showMsg(msg, isError = false) {
|
|
||||||
message.value = msg
|
|
||||||
msgType.value = isError ? 'error' : 'success'
|
|
||||||
setTimeout(() => { message.value = '' }, 3500)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="tool-header">
|
<div class="tool-header">
|
||||||
<router-link to="/" class="back-link">← 返回首页</router-link>
|
<router-link to="/" class="back-link">← 返回首页</router-link>
|
||||||
<h2>⛰️ 高程计算工具</h2>
|
<h2><Mountain class="icon-lg icon-danger" /> 高程计算工具</h2>
|
||||||
<p>高差、坡度、坡度角计算</p>
|
<p>高差、坡度、坡度角计算</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,6 +99,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { Mountain } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
calculateElevationDifference,
|
calculateElevationDifference,
|
||||||
calculateSlope,
|
calculateSlope,
|
||||||
+31
-11
@@ -1,13 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1 class="hero-title">📐 测绘工具箱</h1>
|
<h1 class="hero-title">
|
||||||
|
<Triangle class="inline-icon" /> 测绘工具箱
|
||||||
|
</h1>
|
||||||
<p class="hero-subtitle">专业的测绘计算工具集,助力您的测绘工作</p>
|
<p class="hero-subtitle">专业的测绘计算工具集,助力您的测绘工作</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tools-grid">
|
<div class="tools-grid">
|
||||||
<router-link v-for="tool in tools" :key="tool.path" :to="tool.path" class="tool-card">
|
<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>
|
<h3 class="tool-title">{{ tool.name }}</h3>
|
||||||
<p class="tool-description">{{ tool.description }}</p>
|
<p class="tool-description">{{ tool.description }}</p>
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -17,59 +19,69 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { Triangle, Globe, Ruler, BarChart3, Compass, Mountain, Map, MapPin } from 'lucide-vue-next'
|
||||||
|
|
||||||
const tools = ref([
|
const tools = ref([
|
||||||
{
|
{
|
||||||
name: '坐标转换',
|
name: '坐标转换',
|
||||||
icon: '🌍',
|
icon: Globe,
|
||||||
|
iconClass: 'icon-primary',
|
||||||
description: '经纬度格式转换,支持度分秒与十进制互转',
|
description: '经纬度格式转换,支持度分秒与十进制互转',
|
||||||
path: '/coordinate-converter'
|
path: '/coordinate-converter'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '距离计算',
|
name: '距离计算',
|
||||||
icon: '📏',
|
icon: Ruler,
|
||||||
|
iconClass: 'icon-secondary',
|
||||||
description: '根据两点坐标计算距离,支持经纬度和平面坐标',
|
description: '根据两点坐标计算距离,支持经纬度和平面坐标',
|
||||||
path: '/distance-calculator'
|
path: '/distance-calculator'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '面积计算',
|
name: '面积计算',
|
||||||
icon: '📐',
|
icon: Triangle,
|
||||||
|
iconClass: 'icon-accent',
|
||||||
description: '多边形面积计算,支持任意多边形',
|
description: '多边形面积计算,支持任意多边形',
|
||||||
path: '/area-calculator'
|
path: '/area-calculator'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '角度转换',
|
name: '角度转换',
|
||||||
icon: '📊',
|
icon: BarChart3,
|
||||||
|
iconClass: 'icon-success',
|
||||||
description: '度分秒、十进制度数、弧度之间的转换',
|
description: '度分秒、十进制度数、弧度之间的转换',
|
||||||
path: '/angle-converter'
|
path: '/angle-converter'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '方位角计算',
|
name: '方位角计算',
|
||||||
icon: '🧭',
|
icon: Compass,
|
||||||
|
iconClass: 'icon-warning',
|
||||||
description: '根据两点坐标计算方位角和象限角',
|
description: '根据两点坐标计算方位角和象限角',
|
||||||
path: '/bearing-calculator'
|
path: '/bearing-calculator'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '高程计算',
|
name: '高程计算',
|
||||||
icon: '⛰️',
|
icon: Mountain,
|
||||||
|
iconClass: 'icon-danger',
|
||||||
description: '高差、坡度、坡度角计算',
|
description: '高差、坡度、坡度角计算',
|
||||||
path: '/elevation-calculator'
|
path: '/elevation-calculator'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '高德地名搜索',
|
name: '高德地名搜索',
|
||||||
icon: '🗺️',
|
icon: Map,
|
||||||
|
iconClass: 'icon-gradient',
|
||||||
description: '通过高德API搜索POI,获取坐标并导出',
|
description: '通过高德API搜索POI,获取坐标并导出',
|
||||||
path: '/amap-search'
|
path: '/amap-search'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '图上量测与拾取',
|
name: '图上量测与拾取',
|
||||||
icon: '🗺️',
|
icon: Map,
|
||||||
|
iconClass: 'icon-primary',
|
||||||
description: '在地图上点选坐标、量测距离和面积,实时显示地理与投影坐标',
|
description: '在地图上点选坐标、量测距离和面积,实时显示地理与投影坐标',
|
||||||
path: '/map-interaction'
|
path: '/map-interaction'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '坐标展点',
|
name: '坐标展点',
|
||||||
icon: '📍',
|
icon: MapPin,
|
||||||
|
iconClass: 'icon-secondary',
|
||||||
description: '将坐标批量展绘到地图上,支持手动输入、批量粘贴和Excel导入',
|
description: '将坐标批量展绘到地图上,支持手动输入、批量粘贴和Excel导入',
|
||||||
path: '/coordinate-plotter'
|
path: '/coordinate-plotter'
|
||||||
}
|
}
|
||||||
@@ -93,6 +105,14 @@ const tools = ref([
|
|||||||
animation: fadeIn 0.6s ease;
|
animation: fadeIn 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-icon {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.hero-subtitle {
|
.hero-subtitle {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="container map-tool-container">
|
<div class="container map-tool-container">
|
||||||
<div class="tool-header">
|
<div class="tool-header">
|
||||||
<router-link to="/" class="back-link">← 返回首页</router-link>
|
<router-link to="/" class="back-link">← 返回首页</router-link>
|
||||||
<h2>🗺️ 图上量测与拾取</h2>
|
<h2><Map class="icon-lg icon-primary" /> 图上量测与拾取</h2>
|
||||||
<p>在地图上点选拾取坐标、线选量距、面选量面积,实时显示地理与投影坐标</p>
|
<p>在地图上点选拾取坐标、线选量距、面选量面积,实时显示地理与投影坐标</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
:class="['btn', 'tool-btn', { active: currentMode === tool.mode }]"
|
:class="['btn', 'tool-btn', { active: currentMode === tool.mode }]"
|
||||||
@click="setMode(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>
|
<span class="tool-btn-label">{{ tool.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-secondary clear-btn" @click="clearAll">
|
<button class="btn btn-secondary clear-btn" @click="clearAll">
|
||||||
🗑️ 清除全部
|
<Trash2 class="icon-sm icon-danger" /> 清除全部
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,10 +78,10 @@
|
|||||||
<!-- Results Panel -->
|
<!-- Results Panel -->
|
||||||
<div v-if="results.length > 0" class="results-panel card">
|
<div v-if="results.length > 0" class="results-panel card">
|
||||||
<div class="results-header">
|
<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">
|
<div class="results-actions">
|
||||||
<button class="btn btn-secondary" @click="copyResults">📋 复制</button>
|
<button class="btn btn-secondary" @click="copyResults"><Copy class="icon-sm icon-success" /> 复制</button>
|
||||||
<button class="btn btn-secondary" @click="clearResults">🗑️ 清空</button>
|
<button class="btn btn-secondary" @click="clearResults"><Trash2 class="icon-sm icon-danger" /> 清空</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,7 +94,10 @@
|
|||||||
>
|
>
|
||||||
<div class="result-item-header">
|
<div class="result-item-header">
|
||||||
<span class="result-type-badge" :class="r.type">
|
<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>
|
||||||
<span class="result-index">#{{ i + 1 }}</span>
|
<span class="result-index">#{{ i + 1 }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,22 +157,30 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import L from 'leaflet'
|
import { Map, Trash2, ClipboardList, Copy, MapPin, Ruler, Triangle } from 'lucide-vue-next'
|
||||||
import 'leaflet/dist/leaflet.css'
|
|
||||||
import { gcj02ToWgs84 } from '../utils/amap'
|
import { gcj02ToWgs84 } from '../utils/amap'
|
||||||
import { projectToPlane } from '../utils/proj'
|
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 ────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────
|
||||||
const mapRef = ref(null)
|
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
|
let map = null
|
||||||
|
|
||||||
const currentMode = ref('point')
|
const currentMode = ref('point')
|
||||||
const zoneType = ref(3)
|
const zoneType = ref(3)
|
||||||
const cursorCoord = ref(null)
|
const cursorCoord = ref(null)
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
const message = ref('')
|
|
||||||
const messageType = ref('success')
|
|
||||||
|
|
||||||
// Drawing state
|
// Drawing state
|
||||||
let drawingPoints = []
|
let drawingPoints = []
|
||||||
@@ -178,9 +189,9 @@ let drawingMarkers = []
|
|||||||
let tempLine = null
|
let tempLine = null
|
||||||
|
|
||||||
const toolModes = [
|
const toolModes = [
|
||||||
{ mode: 'point', icon: '📍', label: '点选拾取' },
|
{ mode: 'point', icon: MapPin, iconClass: 'icon-primary', label: '点选拾取' },
|
||||||
{ mode: 'line', icon: '📏', label: '线选量距' },
|
{ mode: 'line', icon: Ruler, iconClass: 'icon-secondary', label: '线选量距' },
|
||||||
{ mode: 'polygon', icon: '📐', label: '面选量面' }
|
{ mode: 'polygon', icon: Triangle, iconClass: 'icon-accent', label: '面选量面' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const currentModeHint = computed(() => {
|
const currentModeHint = computed(() => {
|
||||||
@@ -194,36 +205,11 @@ const currentModeHint = computed(() => {
|
|||||||
|
|
||||||
// ─── Map Initialization ──────────────────────────────
|
// ─── Map Initialization ──────────────────────────────
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initMap()
|
map = createMap()
|
||||||
})
|
|
||||||
|
|
||||||
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.on('mousemove', onMouseMove)
|
map.on('mousemove', onMouseMove)
|
||||||
map.on('click', onMapClick)
|
map.on('click', onMapClick)
|
||||||
map.on('dblclick', onMapDoubleClick)
|
map.on('dblclick', onMapDoubleClick)
|
||||||
}
|
})
|
||||||
|
|
||||||
// ─── Coordinate Conversion ───────────────────────────
|
// ─── Coordinate Conversion ───────────────────────────
|
||||||
function convertCoord(latlng) {
|
function convertCoord(latlng) {
|
||||||
@@ -342,29 +328,12 @@ function onMapDoubleClick() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function finishLine() {
|
function finishLine() {
|
||||||
// Calculate distance using haversine via turf-like approach
|
const wgsCoords = drawingPoints.map((p) => {
|
||||||
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 [wLng, wLat] = gcj02ToWgs84(p[1], p[0])
|
const [wLng, wLat] = gcj02ToWgs84(p[1], p[0])
|
||||||
return [wLat, wLng]
|
return [wLng, wLat]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Recalculate with WGS-84 coords
|
const wgsDist = turfLength(lineString(wgsCoords), { units: 'meters' })
|
||||||
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 distanceText = wgsDist > 1000
|
const distanceText = wgsDist > 1000
|
||||||
? `${(wgsDist / 1000).toFixed(3)} km`
|
? `${(wgsDist / 1000).toFixed(3)} km`
|
||||||
@@ -416,24 +385,16 @@ function finishPolygon() {
|
|||||||
}).addTo(map)
|
}).addTo(map)
|
||||||
|
|
||||||
// Convert to WGS-84 for accurate measurement
|
// 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])
|
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 areaValue = turfArea(turfPolygon([closedCoords]))
|
||||||
const area = sphericalPolygonArea(wgsPoints)
|
const areaText = formatArea(areaValue)
|
||||||
const areaText = formatArea(area)
|
|
||||||
|
|
||||||
// Calculate perimeter
|
const perimeter = turfLength(lineString(closedCoords), { units: 'meters' })
|
||||||
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 perimeterText = perimeter > 1000
|
const perimeterText = perimeter > 1000
|
||||||
? `${(perimeter / 1000).toFixed(3)} km`
|
? `${(perimeter / 1000).toFixed(3)} km`
|
||||||
: `${perimeter.toFixed(2)} m`
|
: `${perimeter.toFixed(2)} m`
|
||||||
@@ -450,7 +411,7 @@ function finishPolygon() {
|
|||||||
results.value.push({
|
results.value.push({
|
||||||
type: 'polygon',
|
type: 'polygon',
|
||||||
areaText,
|
areaText,
|
||||||
area,
|
area: areaValue,
|
||||||
perimeterText,
|
perimeterText,
|
||||||
perimeter,
|
perimeter,
|
||||||
pointCount: drawingPoints.length
|
pointCount: drawingPoints.length
|
||||||
@@ -460,34 +421,6 @@ function finishPolygon() {
|
|||||||
resetDrawing()
|
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 ─────────────────────────────────────
|
// ─── Format Area ─────────────────────────────────────
|
||||||
function formatArea(sqm) {
|
function formatArea(sqm) {
|
||||||
if (sqm > 1e6) {
|
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 zone type change ──────────────────────────
|
||||||
watch(zoneType, () => {
|
watch(zoneType, () => {
|
||||||
if (cursorCoord.value) {
|
if (cursorCoord.value) {
|
||||||
@@ -11,4 +11,16 @@ export default defineConfig({
|
|||||||
open: false, // 启动时不自动打开浏览器
|
open: false, // 启动时不自动打开浏览器
|
||||||
},
|
},
|
||||||
base: '/geo-tools/',
|
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