Files
geo-tools/src/components/AreaCalculator.vue

352 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>