352 lines
12 KiB
Vue
352 lines
12 KiB
Vue
<template>
|
||
<div class="container">
|
||
<div class="tool-header">
|
||
<router-link to="/" class="back-link">
|
||
<ChevronLeft class="icon-sm" /> 返回首页
|
||
</router-link>
|
||
<h2>
|
||
<SquareStack class="icon-lg" /> 面积计算工具
|
||
</h2>
|
||
<p>基于 Turf.js 的地理几何计算,支持球面/平面面积</p>
|
||
</div>
|
||
|
||
<div class="grid grid-2 gap-xl">
|
||
<!-- Points Input -->
|
||
<div class="card">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||
<h3 class="card-title" style="margin: 0; display: flex; align-items: center; gap: 8px;">
|
||
<LayoutList class="icon-md" /> 顶点坐标 (经度, 纬度)
|
||
</h3>
|
||
<button @click="addPoint" class="btn btn-secondary btn-sm" style="display: flex; align-items: center; gap: 4px; padding: 4px 12px; font-size: 0.875rem;">
|
||
➕ 添加顶点
|
||
</button>
|
||
</div>
|
||
|
||
<div class="mt-lg mb-md grid grid-2 gap-sm">
|
||
<div class="input-group">
|
||
<label class="input-label">坐标类型</label>
|
||
<select v-model="coordType" class="input" @change="handleModeChange">
|
||
<option value="lnglat">经纬度 (Lng/Lat)</option>
|
||
<option value="planar">平面坐标 (X/Y)</option>
|
||
</select>
|
||
</div>
|
||
<div class="input-group">
|
||
<label class="input-label">计算方式</label>
|
||
<select v-model="calcMethod" class="input" :disabled="coordType === 'planar'">
|
||
<option value="geodetic">测地线 (严密球面)</option>
|
||
<option value="planar">简单平面 (直角公式)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="points-list">
|
||
<div v-for="(point, index) in points" :key="index" class="point-input">
|
||
<span class="point-index">{{ index + 1 }}</span>
|
||
|
||
<template v-if="coordType === 'lnglat'">
|
||
<input type="number" v-model.number="point.lng" class="input" placeholder="经度 (Lon)" step="0.000001">
|
||
<input type="number" v-model.number="point.lat" class="input" placeholder="纬度 (Lat)" step="0.000001">
|
||
</template>
|
||
<template v-else>
|
||
<input type="number" v-model.number="point.x" class="input" placeholder="X坐标 (北移)">
|
||
<input type="number" v-model.number="point.y" class="input" placeholder="Y坐标 (东移)">
|
||
</template>
|
||
|
||
<button v-if="points.length > 3" @click="removePoint(index)" class="btn-remove" title="删除">
|
||
<Trash2 class="icon-sm" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-lg">
|
||
<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-value">{{ formatArea(area) }}</div>
|
||
</div>
|
||
|
||
<div class="result-grid mt-xl">
|
||
<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>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref } from 'vue'
|
||
import * as turf from '@turf/turf'
|
||
import { ChevronLeft, SquareStack, LayoutList, Trash2, Activity, Info } from 'lucide-vue-next'
|
||
|
||
const points = ref([
|
||
{ lng: 108.5597255, lat: 22.8589543, x: 36557444.57, y: 2529026.75 },
|
||
{ lng: 108.5607409, lat: 22.8589507, x: 36557548.78, y: 2529026.75 },
|
||
{ lng: 108.5607373, lat: 22.8580860, x: 36557548.78, y: 2528930.99 },
|
||
{ lng: 108.5597220, lat: 22.8580896, x: 36557444.57, y: 2528930.99 }
|
||
])
|
||
|
||
const coordType = ref('lnglat')
|
||
const calcMethod = ref('geodetic')
|
||
const area = ref(null)
|
||
const perimeter = ref(0)
|
||
|
||
const handleModeChange = () => {
|
||
area.value = null
|
||
perimeter.value = 0
|
||
if (coordType.value === 'planar') {
|
||
calcMethod.value = 'planar'
|
||
} else {
|
||
calcMethod.value = 'geodetic'
|
||
}
|
||
}
|
||
|
||
const addPoint = () => {
|
||
const last = points.value[points.value.length - 1]
|
||
points.value.push({
|
||
lng: last.lng !== undefined ? Number(last.lng) + 0.001 : 116.391,
|
||
lat: last.lat !== undefined ? Number(last.lat) : 39.907,
|
||
x: last.x !== undefined ? Number(last.x) + 100 : 4419500,
|
||
y: last.y !== undefined ? Number(last.y) : 39533900
|
||
})
|
||
}
|
||
|
||
const removePoint = (index) => {
|
||
points.value.splice(index, 1)
|
||
}
|
||
|
||
const calculatePlanar = (pts) => {
|
||
let a = 0;
|
||
let p = 0;
|
||
const n = pts.length;
|
||
for (let i = 0; i < n; i++) {
|
||
const j = (i + 1) % n;
|
||
// Shoelace: sum(x_i * y_{i+1} - x_{i+1} * y_i)
|
||
a += pts[i].x * pts[j].y;
|
||
a -= pts[j].x * pts[i].y;
|
||
|
||
// Perimeter
|
||
const dx = pts[j].x - pts[i].x;
|
||
const dy = pts[j].y - pts[i].y;
|
||
p += Math.sqrt(dx*dx + dy*dy);
|
||
}
|
||
return { area: Math.abs(a / 2), perimeter: p };
|
||
}
|
||
|
||
const calculate = () => {
|
||
try {
|
||
// Ensure all inputs are numbers
|
||
const rawPoints = points.value.map(p => ({
|
||
lng: Number(p.lng || 0),
|
||
lat: Number(p.lat || 0),
|
||
x: Number(p.x || 0),
|
||
y: Number(p.y || 0)
|
||
}));
|
||
|
||
if (coordType.value === 'lnglat' && calcMethod.value === 'geodetic') {
|
||
const coords = rawPoints.map(p => [p.lng, p.lat])
|
||
coords.push(coords[0])
|
||
const polygon = turf.polygon([coords])
|
||
area.value = turf.area(polygon)
|
||
perimeter.value = turf.length(polygon, { units: 'meters' })
|
||
} else {
|
||
let pts = [];
|
||
if (coordType.value === 'planar') {
|
||
// *** 关键改进:使用相对坐标偏移,防止大数计算丢失精度 ***
|
||
const x0 = rawPoints[0].x;
|
||
const y0 = rawPoints[0].y;
|
||
pts = rawPoints.map(p => ({ x: p.x - x0, y: p.y - y0 }));
|
||
} else {
|
||
// Approximate LngLat to meters using Equirectangular projection at local latitude
|
||
const latAvg = rawPoints.reduce((acc, cur) => acc + cur.lat, 0) / rawPoints.length;
|
||
const radLat = latAvg * Math.PI / 180;
|
||
const kx = 111319.49 * Math.cos(radLat);
|
||
const ky = 111319.49;
|
||
|
||
// Use relative coordinates to avoid large number precision issues with degree calculation
|
||
const lng0 = rawPoints[0].lng;
|
||
const lat0 = rawPoints[0].lat;
|
||
|
||
pts = rawPoints.map(p => ({
|
||
x: (p.lng - lng0) * kx,
|
||
y: (p.lat - lat0) * ky
|
||
}));
|
||
}
|
||
|
||
if (pts.length < 3) {
|
||
alert('顶点数量不足')
|
||
return
|
||
}
|
||
const res = calculatePlanar(pts)
|
||
area.value = res.area
|
||
perimeter.value = res.perimeter
|
||
}
|
||
} catch (e) {
|
||
alert('计算失败: ' + e.message)
|
||
}
|
||
}
|
||
|
||
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>
|
||
|
||
<style scoped>
|
||
.tool-header {
|
||
text-align: center;
|
||
margin-bottom: var(--spacing-xl);
|
||
}
|
||
|
||
.back-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: var(--text-secondary);
|
||
text-decoration: none;
|
||
margin-bottom: var(--spacing-md);
|
||
transition: color var(--transition-base);
|
||
}
|
||
|
||
.back-link:hover {
|
||
color: var(--primary);
|
||
}
|
||
|
||
.points-list {
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.point-input {
|
||
display: grid;
|
||
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);
|
||
}
|
||
|
||
.btn-remove {
|
||
background: transparent;
|
||
color: var(--danger);
|
||
border: 1px solid var(--danger);
|
||
border-radius: var(--border-radius-sm);
|
||
width: 32px;
|
||
height: 32px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.btn-remove:hover {
|
||
background: var(--danger);
|
||
color: white;
|
||
}
|
||
|
||
.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;
|
||
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);
|
||
}
|
||
|
||
.res-item {
|
||
background: var(--bg-tertiary);
|
||
padding: var(--spacing-md);
|
||
border-radius: var(--border-radius-sm);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.res-label {
|
||
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) {
|
||
.point-input {
|
||
grid-template-columns: 25px 1fr 1fr 32px;
|
||
}
|
||
}
|
||
</style>
|