From 88e201e50bab647147a085906add3b6953b86a92 Mon Sep 17 00:00:00 2001 From: missum Date: Wed, 12 Aug 2026 09:26:15 +0800 Subject: [PATCH] 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 --- .prettierrc.json | 10 + README.md | 73 +- eslint.config.js | 31 + package-lock.json | 4648 +++++++++-------- package.json | 24 +- src/App.vue | 12 +- src/assets/css/index.css | 57 + src/composables/useLeafletMap.js | 58 + src/composables/useToast.js | 24 + src/router.js | 30 +- src/utils/amap.js | 75 +- src/utils/coordinate.js | 65 +- src/utils/coordinate.test.js | 56 + src/utils/excel.js | 118 + src/utils/geometry.js | 44 +- src/utils/geometry.test.js | 42 + src/utils/proj.test.js | 53 + src/{components => views}/AmapSearchTool.vue | 22 +- src/{components => views}/AngleConverter.vue | 8 +- src/{components => views}/AreaCalculator.vue | 10 +- .../BearingCalculator.vue | 0 .../CoordinateConverter.vue | 232 +- .../CoordinatePlotter.vue | 117 +- .../DistanceCalculator.vue | 0 .../ElevationCalculator.vue | 3 +- src/views/HomeView.vue | 42 +- src/{components => views}/MapInteraction.vue | 150 +- vite.config.js | 12 + vitest.config.js | 10 + 29 files changed, 3270 insertions(+), 2756 deletions(-) create mode 100644 .prettierrc.json create mode 100644 eslint.config.js create mode 100644 src/composables/useLeafletMap.js create mode 100644 src/composables/useToast.js create mode 100644 src/utils/coordinate.test.js create mode 100644 src/utils/excel.js create mode 100644 src/utils/geometry.test.js create mode 100644 src/utils/proj.test.js rename src/{components => views}/AmapSearchTool.vue (97%) rename src/{components => views}/AngleConverter.vue (95%) rename src/{components => views}/AreaCalculator.vue (97%) rename src/{components => views}/BearingCalculator.vue (100%) rename src/{components => views}/CoordinateConverter.vue (79%) rename src/{components => views}/CoordinatePlotter.vue (90%) rename src/{components => views}/DistanceCalculator.vue (100%) rename src/{components => views}/ElevationCalculator.vue (98%) rename src/{components => views}/MapInteraction.vue (86%) create mode 100644 vitest.config.js diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..61d8d52 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "tabWidth": 4, + "useTabs": false, + "endOfLine": "auto", + "vueIndentScriptAndStyle": false +} diff --git a/README.md b/README.md index 1511959..fcd8eba 100644 --- a/README.md +++ b/README.md @@ -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 ` diff --git a/src/assets/css/index.css b/src/assets/css/index.css index da8fee3..6117c87 100644 --- a/src/assets/css/index.css +++ b/src/assets/css/index.css @@ -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; diff --git a/src/composables/useLeafletMap.js b/src/composables/useLeafletMap.js new file mode 100644 index 0000000..0324f0d --- /dev/null +++ b/src/composables/useLeafletMap.js @@ -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} 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 + } +} diff --git a/src/composables/useToast.js b/src/composables/useToast.js new file mode 100644 index 0000000..4488b1e --- /dev/null +++ b/src/composables/useToast.js @@ -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 + } +} diff --git a/src/router.js b/src/router.js index d336875..70801d8 100644 --- a/src/router.js +++ b/src/router.js @@ -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') } ] diff --git a/src/utils/amap.js b/src/utils/amap.js index 1709c34..afd99e7 100644 --- a/src/utils/amap.js +++ b/src/utils/amap.js @@ -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)) + ] } diff --git a/src/utils/coordinate.js b/src/utils/coordinate.js index 697eaa3..3e96edd 100644 --- a/src/utils/coordinate.js +++ b/src/utils/coordinate.js @@ -10,7 +10,7 @@ * @returns {number} 十进制度数 */ export function dmsToDecimal(degrees, minutes, seconds) { - return degrees + minutes / 60 + seconds / 3600; + return degrees + minutes / 60 + seconds / 3600; } /** @@ -19,17 +19,28 @@ export function dmsToDecimal(degrees, minutes, seconds) { * @returns {{degrees: number, minutes: number, seconds: number}} */ export function decimalToDms(decimal) { - const absDecimal = Math.abs(decimal); - const degrees = Math.floor(absDecimal); - const minutesDecimal = (absDecimal - degrees) * 60; - const minutes = Math.floor(minutesDecimal); - const seconds = (minutesDecimal - minutes) * 60; + const isNegative = decimal < 0; + const absDecimal = Math.abs(decimal); + let degrees = Math.floor(absDecimal); + let minutesDecimal = (absDecimal - degrees) * 60; + let minutes = Math.floor(minutesDecimal); + let seconds = (minutesDecimal - minutes) * 60; - return { - degrees: decimal < 0 ? -degrees : degrees, - minutes, - seconds: parseFloat(seconds.toFixed(6)) - }; + seconds = parseFloat(seconds.toFixed(6)); + if (seconds >= 60) { + seconds = 0; + minutes += 1; + } + if (minutes >= 60) { + minutes = 0; + degrees += 1; + } + + return { + degrees: isNegative ? -degrees : degrees, + minutes, + seconds + }; } /** @@ -41,18 +52,18 @@ export function decimalToDms(decimal) { * @returns {number} 距离(米) */ export function haversineDistance(lat1, lon1, lat2, lon2) { - const R = 6371000; // 地球半径(米) - const φ1 = lat1 * Math.PI / 180; - const φ2 = lat2 * Math.PI / 180; - const Δφ = (lat2 - lat1) * Math.PI / 180; - const Δλ = (lon2 - lon1) * Math.PI / 180; + const R = 6371000; // 地球半径(米) + const φ1 = lat1 * Math.PI / 180; + const φ2 = lat2 * Math.PI / 180; + const Δφ = (lat2 - lat1) * Math.PI / 180; + const Δλ = (lon2 - lon1) * Math.PI / 180; - const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + - Math.cos(φ1) * Math.cos(φ2) * - Math.sin(Δλ / 2) * Math.sin(Δλ / 2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * + Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; + return R * c; } /** @@ -64,7 +75,7 @@ export function haversineDistance(lat1, lon1, lat2, lon2) { * @returns {number} 距离 */ export function planarDistance(x1, y1, x2, y2) { - return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); + return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); } /** @@ -74,10 +85,10 @@ export function planarDistance(x1, y1, x2, y2) { * @returns {string} 格式化的坐标字符串 */ export function formatCoordinate(decimal, type) { - const dms = decimalToDms(decimal); - const direction = type === 'lat' - ? (decimal >= 0 ? 'N' : 'S') - : (decimal >= 0 ? 'E' : 'W'); + const dms = decimalToDms(decimal); + const direction = type === 'lat' + ? (decimal >= 0 ? 'N' : 'S') + : (decimal >= 0 ? 'E' : 'W'); - return `${Math.abs(dms.degrees)}°${dms.minutes}'${dms.seconds.toFixed(4)}" ${direction}`; + return `${Math.abs(dms.degrees)}°${dms.minutes}'${dms.seconds.toFixed(4)}" ${direction}`; } diff --git a/src/utils/coordinate.test.js b/src/utils/coordinate.test.js new file mode 100644 index 0000000..159f957 --- /dev/null +++ b/src/utils/coordinate.test.js @@ -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) + }) +}) diff --git a/src/utils/excel.js b/src/utils/excel.js new file mode 100644 index 0000000..b25156a --- /dev/null +++ b/src/utils/excel.js @@ -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>, 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>} 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>} 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) +} diff --git a/src/utils/geometry.js b/src/utils/geometry.js index 691ede9..b6056de 100644 --- a/src/utils/geometry.js +++ b/src/utils/geometry.js @@ -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)) - }; -} diff --git a/src/utils/geometry.test.js b/src/utils/geometry.test.js new file mode 100644 index 0000000..0641f82 --- /dev/null +++ b/src/utils/geometry.test.js @@ -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') + }) +}) diff --git a/src/utils/proj.test.js b/src/utils/proj.test.js new file mode 100644 index 0000000..3176d3d --- /dev/null +++ b/src/utils/proj.test.js @@ -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) + }) +}) diff --git a/src/components/AmapSearchTool.vue b/src/views/AmapSearchTool.vue similarity index 97% rename from src/components/AmapSearchTool.vue rename to src/views/AmapSearchTool.vue index 445f05d..2eb8a43 100644 --- a/src/components/AmapSearchTool.vue +++ b/src/views/AmapSearchTool.vue @@ -146,8 +146,9 @@ diff --git a/src/components/CoordinatePlotter.vue b/src/views/CoordinatePlotter.vue similarity index 90% rename from src/components/CoordinatePlotter.vue rename to src/views/CoordinatePlotter.vue index 3cab464..4e1d0a8 100644 --- a/src/components/CoordinatePlotter.vue +++ b/src/views/CoordinatePlotter.vue @@ -2,7 +2,7 @@
← 返回首页 -

📍 坐标展点

+

坐标展点

将输入或计算得到的坐标批量展绘到地图上,直观展示分布

@@ -47,7 +47,7 @@ :class="['tab-btn', { active: activeTab === tab.key }]" @click="activeTab = tab.key" > - {{ tab.icon }} {{ tab.label }} + {{ tab.label }}
@@ -147,11 +147,11 @@
-

📊 坐标列表 ({{ points.length }} 个)

+

坐标列表 ({{ points.length }} 个)

- - - + + +
@@ -190,16 +190,22 @@