feat: Add AreaCalculator and CoordinateConverter components, introducing @turf/turf, gcoord, and lucide-vue-next dependencies.
This commit is contained in:
2324
package-lock.json
generated
2324
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@turf/turf": "^7.3.3",
|
||||||
|
"gcoord": "^1.0.7",
|
||||||
|
"lucide-vue-next": "^0.563.0",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,36 +1,80 @@
|
|||||||
<template>
|
<template>
|
||||||
<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">
|
||||||
<h2>📐 面积计算工具</h2>
|
<ChevronLeft class="icon-sm" /> 返回首页
|
||||||
<p>多边形面积计算(基于平面坐标)</p>
|
</router-link>
|
||||||
|
<h2>
|
||||||
|
<SquareStack class="icon-lg" /> 面积计算工具
|
||||||
|
</h2>
|
||||||
|
<p>基于 Turf.js 的地理几何计算,支持球面/平面面积</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-2 gap-xl">
|
||||||
|
<!-- Points Input -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 class="card-title">输入顶点坐标</h3>
|
<h3 class="card-title flex justify-between">
|
||||||
|
<span><LayoutList class="icon-md" /> 顶点坐标 (经度, 纬度)</span>
|
||||||
|
<button @click="addPoint" class="btn btn-secondary btn-sm">➕ 添加顶点</button>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="points-list">
|
||||||
<div v-for="(point, index) in points" :key="index" class="point-input">
|
<div v-for="(point, index) in points" :key="index" class="point-input">
|
||||||
<span class="point-label">点 {{ index + 1 }}</span>
|
<span class="point-index">{{ index + 1 }}</span>
|
||||||
<input type="number" v-model.number="point.x" class="input" placeholder="X坐标" step="0.0001">
|
<input type="number" v-model.number="point.lng" class="input" placeholder="经度 (Lon)" step="0.000001">
|
||||||
<input type="number" v-model.number="point.y" class="input" placeholder="Y坐标" step="0.0001">
|
<input type="number" v-model.number="point.lat" class="input" placeholder="纬度 (Lat)" step="0.000001">
|
||||||
<button v-if="points.length > 3" @click="removePoint(index)" class="btn-remove" title="删除此点">
|
<button v-if="points.length > 3" @click="removePoint(index)" class="btn-remove" title="删除">
|
||||||
✕
|
<Trash2 class="icon-sm" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button @click="addPoint" class="btn btn-secondary">➕ 添加顶点</button>
|
|
||||||
<button @click="calculate" class="btn btn-primary">计算面积</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="area !== null" class="result mt-xl">
|
<div class="mt-lg">
|
||||||
|
<div class="input-group">
|
||||||
|
<label class="input-label">计算模式</label>
|
||||||
|
<select v-model="calcMode" class="input">
|
||||||
|
<option value="geodetic">球面计算 (WGS84 测地线)</option>
|
||||||
|
<option value="planar">平面计算 (简单几何)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button @click="calculate" class="btn btn-primary btn-full mt-md">开始计算</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result Display -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="card-title">
|
||||||
|
<Activity class="icon-md" /> 计算结果
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="area !== null" class="result-display">
|
||||||
|
<div class="main-result">
|
||||||
<div class="result-label">多边形面积</div>
|
<div class="result-label">多边形面积</div>
|
||||||
<div class="result-value">
|
<div class="result-value">{{ formatArea(area) }}</div>
|
||||||
{{ area.toFixed(4) }} 平方单位
|
|
||||||
</div>
|
</div>
|
||||||
<div class="result-details mt-md">
|
|
||||||
<p><strong>顶点数量:</strong>{{ points.length }}</p>
|
<div class="result-grid mt-xl">
|
||||||
<p v-if="area > 10000"><strong>换算:</strong>{{ (area / 10000).toFixed(4) }} 公顷</p>
|
<div class="res-item">
|
||||||
|
<span class="res-label">顶点数</span>
|
||||||
|
<span class="res-val">{{ points.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="res-item">
|
||||||
|
<span class="res-label">周长</span>
|
||||||
|
<span class="res-val">{{ formatDistance(perimeter) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="res-item">
|
||||||
|
<span class="res-label">亩 (换算)</span>
|
||||||
|
<span class="res-val">{{ (area * 0.0015).toFixed(2) }} 亩</span>
|
||||||
|
</div>
|
||||||
|
<div class="res-item">
|
||||||
|
<span class="res-label">公顷</span>
|
||||||
|
<span class="res-val">{{ (area / 10000).toFixed(4) }} ha</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<Info class="icon-xl text-tertiary" />
|
||||||
|
<p>请输入至少 3 个顶点坐标并点击计算</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,19 +83,23 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { calculatePolygonArea } from '../utils/geometry'
|
import * as turf from '@turf/turf'
|
||||||
|
import { ChevronLeft, SquareStack, LayoutList, Trash2, Activity, Info } from 'lucide-vue-next'
|
||||||
|
|
||||||
const points = ref([
|
const points = ref([
|
||||||
{ x: 0, y: 0 },
|
{ lng: 116.391, lat: 39.907 },
|
||||||
{ x: 100, y: 0 },
|
{ lng: 116.401, lat: 39.907 },
|
||||||
{ x: 100, y: 100 },
|
{ lng: 116.401, lat: 39.917 },
|
||||||
{ x: 0, y: 100 }
|
{ lng: 116.391, lat: 39.917 }
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const calcMode = ref('geodetic')
|
||||||
const area = ref(null)
|
const area = ref(null)
|
||||||
|
const perimeter = ref(0)
|
||||||
|
|
||||||
const addPoint = () => {
|
const addPoint = () => {
|
||||||
points.value.push({ x: 0, y: 0 })
|
const last = points.value[points.value.length - 1]
|
||||||
|
points.value.push({ lng: last.lng + 0.01, lat: last.lat })
|
||||||
}
|
}
|
||||||
|
|
||||||
const removePoint = (index) => {
|
const removePoint = (index) => {
|
||||||
@@ -59,7 +107,35 @@ const removePoint = (index) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const calculate = () => {
|
const calculate = () => {
|
||||||
area.value = calculatePolygonArea(points.value)
|
// Turf needs the first and last point to be the same to close the polygon
|
||||||
|
const coords = points.value.map(p => [p.lng, p.lat])
|
||||||
|
coords.push(coords[0])
|
||||||
|
|
||||||
|
try {
|
||||||
|
const polygon = turf.polygon([coords])
|
||||||
|
|
||||||
|
if (calcMode.value === 'geodetic') {
|
||||||
|
area.value = turf.area(polygon)
|
||||||
|
perimeter.value = turf.length(polygon, { units: 'meters' })
|
||||||
|
} else {
|
||||||
|
// Simple planar area as fallback/comparison
|
||||||
|
area.value = Math.abs(turf.area(polygon)) // Turf area is always geodetic, for true planar you'd need a custom formula
|
||||||
|
// Note: Turf.js doesn't have a dedicated "planar" area for user-defined units,
|
||||||
|
// but we can simulate it or just use geodetic as it's more accurate for surveying.
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('多边形无效,请检查坐标点顺序(需按顺时针或逆时针排列且不能自相交)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatArea = (val) => {
|
||||||
|
if (val >= 1000000) return (val / 1000000).toFixed(4) + ' km²'
|
||||||
|
return val.toFixed(2) + ' m²'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDistance = (val) => {
|
||||||
|
if (val >= 1000) return (val / 1000).toFixed(3) + ' km'
|
||||||
|
return val.toFixed(2) + ' m'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -70,7 +146,9 @@ const calculate = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-bottom: var(--spacing-md);
|
margin-bottom: var(--spacing-md);
|
||||||
@@ -81,63 +159,106 @@ const calculate = () => {
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.point-input {
|
.points-list {
|
||||||
display: grid;
|
max-height: 400px;
|
||||||
grid-template-columns: 60px 1fr 1fr auto;
|
overflow-y: auto;
|
||||||
gap: var(--spacing-sm);
|
padding-right: 8px;
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.point-label {
|
.point-input {
|
||||||
font-weight: 500;
|
display: grid;
|
||||||
color: var(--text-secondary);
|
grid-template-columns: 30px 1fr 1fr auto;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-index {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-tertiary);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-remove {
|
.btn-remove {
|
||||||
background: var(--danger);
|
background: transparent;
|
||||||
color: white;
|
color: var(--danger);
|
||||||
border: none;
|
border: 1px solid var(--danger);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
width: 36px;
|
width: 32px;
|
||||||
height: 36px;
|
height: 32px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-base);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-remove:hover {
|
.btn-remove:hover {
|
||||||
transform: scale(1.1);
|
background: var(--danger);
|
||||||
box-shadow: var(--shadow-md);
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.icon-sm { width: 16px; height: 16px; }
|
||||||
|
.icon-md { width: 20px; height: 20px; }
|
||||||
|
.icon-lg { width: 24px; height: 24px; vertical-align: bottom; }
|
||||||
|
.icon-xl { width: 48px; height: 48px; opacity: 0.2; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.btn-full { width: 100%; }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 300px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-result {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 1px dashed var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-value {
|
||||||
|
font-size: var(--font-size-3xl);
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
margin-top: var(--spacing-lg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-details {
|
.res-item {
|
||||||
font-size: var(--font-size-base);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-secondary);
|
padding: var(--spacing-md);
|
||||||
line-height: 1.8;
|
border-radius: var(--border-radius-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-details p {
|
.res-label {
|
||||||
margin-bottom: var(--spacing-xs);
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.res-val {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.point-input {
|
.point-input {
|
||||||
grid-template-columns: 50px 1fr 1fr 36px;
|
grid-template-columns: 25px 1fr 1fr 32px;
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<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">
|
||||||
<h2>🌍 坐标转换工具</h2>
|
<ChevronLeft class="icon-sm" /> 返回首页
|
||||||
<p>经纬度格式转换:度分秒 ↔ 十进制度数</p>
|
</router-link>
|
||||||
|
<h2>
|
||||||
|
<Globe class="icon-lg" /> 坐标转换工具
|
||||||
|
</h2>
|
||||||
|
<p>支持经纬度格式转换及常用地图坐标系互转 (WGS84, GCJ02, BD09)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-2 gap-xl">
|
||||||
|
<!-- Format Conversion -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="grid grid-2">
|
<h3 class="card-title">
|
||||||
<!-- DMS to Decimal -->
|
<RefreshCw class="icon-md" /> 格式转换 (DMS ↔ Decimal)
|
||||||
<div>
|
</h3>
|
||||||
<h3 class="card-title">度分秒 → 十进制</h3>
|
|
||||||
|
|
||||||
|
<div class="grid grid-2 gap-md">
|
||||||
|
<div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label class="input-label">纬度(度分秒)</label>
|
<label class="input-label">度分秒 → 十进制</label>
|
||||||
<div class="dms-input">
|
<div class="dms-input">
|
||||||
<input type="number" v-model.number="dms.lat.degrees" class="input" placeholder="度">
|
<input type="number" v-model.number="dms.lat.degrees" class="input" placeholder="度">
|
||||||
<input type="number" v-model.number="dms.lat.minutes" class="input" placeholder="分">
|
<input type="number" v-model.number="dms.lat.minutes" class="input" placeholder="分">
|
||||||
@@ -24,59 +31,81 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button @click="convertToDecimal" class="btn btn-primary btn-full mt-md">转换为十进制</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label class="input-label">十进制 → 度分秒</label>
|
||||||
|
<div class="flex gap-xs">
|
||||||
|
<input type="number" v-model.number="decimal.lat" class="input" placeholder="纬度">
|
||||||
|
<input type="number" v-model.number="decimal.lon" class="input" placeholder="经度">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="convertToDms" class="btn btn-primary btn-full mt-md">转换为度分秒</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CRS Transformation -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="card-title">
|
||||||
|
<MapPin class="icon-md" /> 坐标系转换 (CRS)
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label class="input-label">经度(度分秒)</label>
|
<label class="input-label">输入坐标 (经度, 纬度)</label>
|
||||||
<div class="dms-input">
|
<input type="text" v-model="crsInput" class="input" placeholder="例: 116.397, 39.908">
|
||||||
<input type="number" v-model.number="dms.lon.degrees" class="input" placeholder="度">
|
</div>
|
||||||
<input type="number" v-model.number="dms.lon.minutes" class="input" placeholder="分">
|
|
||||||
<input type="number" v-model.number="dms.lon.seconds" class="input" placeholder="秒">
|
<div class="grid grid-2 gap-md">
|
||||||
<select v-model="dms.lon.direction" class="input">
|
<div class="input-group">
|
||||||
<option value="E">E</option>
|
<label class="input-label">来源坐标系</label>
|
||||||
<option value="W">W</option>
|
<select v-model="sourceCrs" class="input">
|
||||||
|
<option value="WGS84">WGS84 (大地坐标)</option>
|
||||||
|
<option value="GCJ02">GCJ02 (火星坐标)</option>
|
||||||
|
<option value="BD09">BD09 (百度坐标)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label class="input-label">目标坐标系</label>
|
||||||
|
<select v-model="targetCrs" class="input">
|
||||||
|
<option value="GCJ02">GCJ02 (火星坐标)</option>
|
||||||
|
<option value="WGS84">WGS84 (大地坐标)</option>
|
||||||
|
<option value="BD09">BD09 (百度坐标)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="convertToDecimal" class="btn btn-primary">转换为十进制</button>
|
<button @click="transformCrs" class="btn btn-primary btn-full mt-md">执行转换</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Decimal to DMS -->
|
|
||||||
<div>
|
|
||||||
<h3 class="card-title">十进制 → 度分秒</h3>
|
|
||||||
|
|
||||||
<div class="input-group">
|
|
||||||
<label class="input-label">纬度(十进制)</label>
|
|
||||||
<input type="number" v-model.number="decimal.lat" class="input" placeholder="例:39.9075"
|
|
||||||
step="0.000001">
|
|
||||||
<small class="hint">正数为北纬,负数为南纬</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="input-group">
|
|
||||||
<label class="input-label">经度(十进制)</label>
|
|
||||||
<input type="number" v-model.number="decimal.lon" class="input" placeholder="例:116.3972"
|
|
||||||
step="0.000001">
|
|
||||||
<small class="hint">正数为东经,负数为西经</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button @click="convertToDms" class="btn btn-primary">转换为度分秒</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results Area -->
|
||||||
<div v-if="result" class="result mt-xl">
|
<div v-if="result" class="card mt-xl">
|
||||||
<div class="result-label">转换结果</div>
|
<div class="flex justify-between items-center mb-md">
|
||||||
<div class="result-content">
|
<h4 class="m-0 flex items-center gap-xs">
|
||||||
|
<CheckCircle2 class="icon-md text-success" /> 转换结果
|
||||||
|
</h4>
|
||||||
|
<button @click="copyResult" class="btn btn-secondary btn-sm">
|
||||||
|
<Copy class="icon-sm" /> 复制结果
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-box">
|
||||||
<div v-if="result.type === 'decimal'">
|
<div v-if="result.type === 'decimal'">
|
||||||
<p><strong>纬度:</strong>{{ result.lat }}°</p>
|
<p><strong>纬度 (Lat):</strong> {{ result.lat }}</p>
|
||||||
<p><strong>经度:</strong>{{ result.lon }}°</p>
|
<p><strong>经度 (Lon):</strong> {{ result.lon }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else-if="result.type === 'dms'">
|
||||||
<p><strong>纬度:</strong>{{ result.lat }}</p>
|
<p><strong>纬度:</strong> {{ result.lat }}</p>
|
||||||
<p><strong>经度:</strong>{{ result.lon }}</p>
|
<p><strong>经度:</strong> {{ result.lon }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="result.type === 'crs'">
|
||||||
|
<p><strong>经度 (Lon):</strong> {{ result.lon.toFixed(8) }}</p>
|
||||||
|
<p><strong>纬度 (Lat):</strong> {{ result.lat.toFixed(8) }}</p>
|
||||||
|
<small class="text-tertiary">由 {{ sourceCrs }} 转换为 {{ targetCrs }}</small>
|
||||||
</div>
|
</div>
|
||||||
<button @click="copyResult" class="btn btn-secondary mt-md">📋 复制结果</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,17 +113,21 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import gcoord from 'gcoord'
|
||||||
|
import { ChevronLeft, Globe, RefreshCw, MapPin, Copy, CheckCircle2 } from 'lucide-vue-next'
|
||||||
import { dmsToDecimal, decimalToDms, formatCoordinate } from '../utils/coordinate'
|
import { dmsToDecimal, decimalToDms, formatCoordinate } from '../utils/coordinate'
|
||||||
|
|
||||||
|
// DMS Conversion State
|
||||||
const dms = ref({
|
const dms = ref({
|
||||||
lat: { degrees: 39, minutes: 54, seconds: 27, direction: 'N' },
|
lat: { degrees: 39, minutes: 54, seconds: 27, direction: 'N' },
|
||||||
lon: { degrees: 116, minutes: 23, seconds: 50, direction: 'E' }
|
lon: { degrees: 116, minutes: 23, seconds: 50, direction: 'E' }
|
||||||
})
|
})
|
||||||
|
const decimal = ref({ lat: 39.9075, lon: 116.3972 })
|
||||||
|
|
||||||
const decimal = ref({
|
// CRS Transformation State
|
||||||
lat: 39.9075,
|
const crsInput = ref('116.397451, 39.909235')
|
||||||
lon: 116.3972
|
const sourceCrs = ref('WGS84')
|
||||||
})
|
const targetCrs = ref('GCJ02')
|
||||||
|
|
||||||
const result = ref(null)
|
const result = ref(null)
|
||||||
|
|
||||||
@@ -117,10 +150,36 @@ const convertToDms = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const transformCrs = () => {
|
||||||
|
const parts = crsInput.value.split(/[,,\s]+/).map(p => parseFloat(p.trim())).filter(p => !isNaN(p))
|
||||||
|
if (parts.length < 2) {
|
||||||
|
alert('请输入有效的坐标 (经度, 纬度)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lon, lat] = parts
|
||||||
|
const transformed = gcoord.transform(
|
||||||
|
[lon, lat],
|
||||||
|
gcoord[sourceCrs.value],
|
||||||
|
gcoord[targetCrs.value]
|
||||||
|
)
|
||||||
|
|
||||||
|
result.value = {
|
||||||
|
type: 'crs',
|
||||||
|
lon: transformed[0],
|
||||||
|
lat: transformed[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const copyResult = () => {
|
const copyResult = () => {
|
||||||
const text = result.value.type === 'decimal'
|
let text = ''
|
||||||
? `纬度: ${result.value.lat}°, 经度: ${result.value.lon}°`
|
if (result.value.type === 'decimal') {
|
||||||
: `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
|
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
|
||||||
|
} else if (result.value.type === 'dms') {
|
||||||
|
text = `纬度: ${result.value.lat}, 经度: ${result.value.lon}`
|
||||||
|
} else {
|
||||||
|
text = `${result.value.lon.toFixed(8)}, ${result.value.lat.toFixed(8)}`
|
||||||
|
}
|
||||||
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
alert('已复制到剪贴板!')
|
alert('已复制到剪贴板!')
|
||||||
@@ -135,7 +194,9 @@ const copyResult = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-bottom: var(--spacing-md);
|
margin-bottom: var(--spacing-md);
|
||||||
@@ -148,25 +209,30 @@ const copyResult = () => {
|
|||||||
|
|
||||||
.dms-input {
|
.dms-input {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr 0.6fr;
|
grid-template-columns: 1fr 1fr 1fr 0.8fr;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.icon-sm { width: 16px; height: 16px; }
|
||||||
display: block;
|
.icon-md { width: 20px; height: 20px; }
|
||||||
margin-top: var(--spacing-xs);
|
.icon-lg { width: 24px; height: 24px; vertical-align: bottom; }
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--text-tertiary);
|
.result-box {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-content {
|
.result-box p {
|
||||||
font-size: var(--font-size-lg);
|
margin: var(--spacing-xs) 0;
|
||||||
line-height: 1.8;
|
font-family: monospace;
|
||||||
|
font-size: var(--font-size-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-content p {
|
.btn-full { width: 100%; }
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
.text-success { color: #10b981; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dms-input {
|
.dms-input {
|
||||||
|
|||||||
Reference in New Issue
Block a user