feat: add map interaction and coordinate plotter tools, optimize dev config
1. 新增图上量测与拾取、坐标展点两个地图工具页面 2. 安装leaflet依赖并配置vite开发服务器允许局域网访问 3. 更新首页工具列表,新增两个工具入口 4. 优化坐标转换器默认参数和文件读取方式 5. 调整面积计算精度,删除无用的HelloWorld组件
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@turf/turf": "^7.3.3",
|
||||
"gcoord": "^1.0.7",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"proj4": "^2.20.4",
|
||||
"vue": "^3.5.24",
|
||||
@@ -3354,6 +3355,12 @@
|
||||
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lucide-vue-next": {
|
||||
"version": "0.563.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.563.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@turf/turf": "^7.3.3",
|
||||
"gcoord": "^1.0.7",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"proj4": "^2.20.4",
|
||||
"vue": "^3.5.24",
|
||||
|
||||
@@ -109,10 +109,10 @@ 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 }
|
||||
{ lng: 108.5597255, lat: 22.8589543, x: 36557444.5725, y: 2529026.7544 },
|
||||
{ lng: 108.5607409, lat: 22.8589507, x: 36557548.7813, y: 2529026.7544 },
|
||||
{ lng: 108.5607373, lat: 22.8580860, x: 36557548.7813, y: 2528930.9851 },
|
||||
{ lng: 108.5597220, lat: 22.8580896, x: 36557444.5725, y: 2528930.9851 }
|
||||
])
|
||||
|
||||
const coordType = ref('lnglat')
|
||||
@@ -217,7 +217,7 @@ const calculate = () => {
|
||||
|
||||
const formatArea = (val) => {
|
||||
if (val >= 1000000) return (val / 1000000).toFixed(4) + ' km²'
|
||||
return val.toFixed(2) + ' m²'
|
||||
return val.toFixed(4) + ' m²'
|
||||
}
|
||||
|
||||
const formatDistance = (val) => {
|
||||
|
||||
@@ -276,11 +276,11 @@ const projConfig = ref({
|
||||
direction: 'toPlane', // or 'toLngLat'
|
||||
zoneType: 3, // 3 or 6
|
||||
withZone: true,
|
||||
lng: 116.397451,
|
||||
lat: 39.909235,
|
||||
x: 39533966.82, // Easting (Y)
|
||||
y: 4419519.82, // Northing (X)
|
||||
manualZone: 39
|
||||
lng: 108.55972199,
|
||||
lat: 22.85808956,
|
||||
x: 36557444.5725, // Easting (Y)
|
||||
y: 2528930.9851, // Northing (X)
|
||||
manualZone: null
|
||||
})
|
||||
|
||||
const result = ref(null)
|
||||
@@ -532,7 +532,7 @@ const handleFileUpload = (e) => {
|
||||
alert('文件解析失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
reader.readAsBinaryString(file)
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
924
src/components/CoordinatePlotter.vue
Normal file
924
src/components/CoordinatePlotter.vue
Normal file
@@ -0,0 +1,924 @@
|
||||
<template>
|
||||
<div class="container plotter-container">
|
||||
<div class="tool-header">
|
||||
<router-link to="/" class="back-link">← 返回首页</router-link>
|
||||
<h2>📍 坐标展点</h2>
|
||||
<p>将输入或计算得到的坐标批量展绘到地图上,直观展示分布</p>
|
||||
</div>
|
||||
|
||||
<div class="plotter-layout">
|
||||
<!-- Left Panel: Input -->
|
||||
<div class="input-panel card">
|
||||
<!-- Coordinate System Selector -->
|
||||
<div class="system-selector">
|
||||
<h3 class="card-title">坐标设置</h3>
|
||||
<div class="input-group">
|
||||
<label class="input-label">坐标类型</label>
|
||||
<select v-model="coordType" class="input">
|
||||
<option value="wgs84">WGS-84 经纬度</option>
|
||||
<option value="cgcs2000">CGCS2000 投影坐标</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="coordType === 'cgcs2000'" class="proj-settings">
|
||||
<div class="input-group">
|
||||
<label class="input-label">分带类型</label>
|
||||
<select v-model="zoneType" class="input">
|
||||
<option :value="3">3°带</option>
|
||||
<option :value="6">6°带</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label class="input-label">带号</label>
|
||||
<input type="number" v-model.number="zoneNumber" class="input" placeholder="例: 35">
|
||||
</div>
|
||||
<label class="radio-item">
|
||||
<input type="checkbox" v-model="withZone" />
|
||||
<span>坐标含带号</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="input-tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:class="['tab-btn', { active: activeTab === tab.key }]"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.icon }} {{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Manual Input -->
|
||||
<div v-if="activeTab === 'manual'" class="tab-content">
|
||||
<div class="manual-form">
|
||||
<div class="input-group">
|
||||
<label class="input-label">
|
||||
{{ coordType === 'wgs84' ? '经度 (Longitude)' : 'X / Easting (m)' }}
|
||||
</label>
|
||||
<input type="text" v-model="manualX" class="input"
|
||||
:placeholder="coordType === 'wgs84' ? '例: 104.0657' : '例: 500000'">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label class="input-label">
|
||||
{{ coordType === 'wgs84' ? '纬度 (Latitude)' : 'Y / Northing (m)' }}
|
||||
</label>
|
||||
<input type="text" v-model="manualY" class="input"
|
||||
:placeholder="coordType === 'wgs84' ? '例: 30.5728' : '例: 3380000'">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label class="input-label">名称(可选)</label>
|
||||
<input type="text" v-model="manualName" class="input" placeholder="标注名称">
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="addManualPoint">
|
||||
➕ 添加到列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Batch Input -->
|
||||
<div v-if="activeTab === 'batch'" class="tab-content">
|
||||
<div class="input-group">
|
||||
<label class="input-label">
|
||||
批量坐标(每行一个:{{ coordType === 'wgs84' ? '经度,纬度[,名称]' : 'X,Y[,名称]' }})
|
||||
</label>
|
||||
<textarea
|
||||
v-model="batchText"
|
||||
class="input textarea"
|
||||
rows="8"
|
||||
:placeholder="batchPlaceholder"
|
||||
></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="parseBatchInput">
|
||||
📥 解析并添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Excel Import -->
|
||||
<div v-if="activeTab === 'excel'" class="tab-content">
|
||||
<div class="file-upload-area" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleFileDrop">
|
||||
<div class="upload-icon">📂</div>
|
||||
<p class="upload-text">点击或拖拽 Excel 文件到此处</p>
|
||||
<p class="upload-hint">.xlsx / .xls / .csv 格式</p>
|
||||
<input type="file" ref="fileInput" @change="handleFileSelect"
|
||||
accept=".xlsx,.xls,.csv" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div v-if="excelColumns.length > 0" class="column-mapping">
|
||||
<h4>列映射</h4>
|
||||
<div class="mapping-row">
|
||||
<label class="input-label">
|
||||
{{ coordType === 'wgs84' ? '经度列' : 'X/Easting 列' }}
|
||||
</label>
|
||||
<select v-model="mappingX" class="input">
|
||||
<option v-for="col in excelColumns" :key="col" :value="col">{{ col }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mapping-row">
|
||||
<label class="input-label">
|
||||
{{ coordType === 'wgs84' ? '纬度列' : 'Y/Northing 列' }}
|
||||
</label>
|
||||
<select v-model="mappingY" class="input">
|
||||
<option v-for="col in excelColumns" :key="col" :value="col">{{ col }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mapping-row">
|
||||
<label class="input-label">名称列(可选)</label>
|
||||
<select v-model="mappingName" class="input">
|
||||
<option value="">-- 不选择 --</option>
|
||||
<option v-for="col in excelColumns" :key="col" :value="col">{{ col }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="importExcelData">
|
||||
📥 导入 {{ excelRawData.length }} 行数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Map -->
|
||||
<div class="map-section">
|
||||
<div class="map-card card">
|
||||
<div id="plotter-map" ref="mapRef"></div>
|
||||
</div>
|
||||
|
||||
<!-- Point List -->
|
||||
<div v-if="points.length > 0" class="points-panel card">
|
||||
<div class="points-header">
|
||||
<h3 class="card-title">📊 坐标列表 ({{ points.length }} 个)</h3>
|
||||
<div class="points-actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="fitMapToPoints">🔍 全部定位</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="exportExcel">📤 导出Excel</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="clearPoints">🗑️ 清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="points-table-wrapper">
|
||||
<table class="points-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>名称</th>
|
||||
<th>经度</th>
|
||||
<th>纬度</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(p, i) in points" :key="i" @click="zoomToPoint(p)" class="point-row">
|
||||
<td>{{ i + 1 }}</td>
|
||||
<td>{{ p.name || '-' }}</td>
|
||||
<td class="mono">{{ p.lng.toFixed(6) }}</td>
|
||||
<td class="mono">{{ p.lat.toFixed(6) }}</td>
|
||||
<td>
|
||||
<button class="btn-icon" @click.stop="removePoint(i)" title="删除">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-if="message" :class="['message-toast', msgType]">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed, nextTick } from 'vue'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { unprojectToLngLat } from '../utils/proj'
|
||||
import * as XLSX from 'xlsx'
|
||||
import gcoord from 'gcoord'
|
||||
|
||||
// ─── State ────────────────────────────────────────────
|
||||
const mapRef = ref(null)
|
||||
const fileInput = ref(null)
|
||||
let map = null
|
||||
let markerGroup = null
|
||||
|
||||
const coordType = ref('wgs84')
|
||||
const zoneType = ref(3)
|
||||
const zoneNumber = ref(35)
|
||||
const withZone = ref(false)
|
||||
const activeTab = ref('manual')
|
||||
|
||||
// Manual input
|
||||
const manualX = ref('')
|
||||
const manualY = ref('')
|
||||
const manualName = ref('')
|
||||
|
||||
// Batch input
|
||||
const batchText = ref('')
|
||||
|
||||
// Excel import
|
||||
const excelColumns = ref([])
|
||||
const excelRawData = ref([])
|
||||
const mappingX = ref('')
|
||||
const mappingY = ref('')
|
||||
const mappingName = ref('')
|
||||
|
||||
// Points data (always stored as WGS-84 lng/lat)
|
||||
const points = ref([])
|
||||
|
||||
// Messages
|
||||
const message = ref('')
|
||||
const msgType = ref('success')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'manual', icon: '✏️', label: '手动输入' },
|
||||
{ key: 'batch', icon: '📝', label: '批量粘贴' },
|
||||
{ key: 'excel', icon: '📊', label: 'Excel导入' }
|
||||
]
|
||||
|
||||
const batchPlaceholder = computed(() => {
|
||||
return coordType.value === 'wgs84'
|
||||
? '104.0657,30.5728,成都\n116.3975,39.9085,北京\n121.4737,31.2304,上海'
|
||||
: '500000,3380000,A点\n501000,3381000,B点'
|
||||
})
|
||||
|
||||
// ─── Map ──────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
initMap()
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ─── Convert coordinate to WGS-84 lng/lat ────────────
|
||||
function toWgs84(x, y) {
|
||||
if (coordType.value === 'wgs84') {
|
||||
return { lng: parseFloat(x), lat: parseFloat(y) }
|
||||
}
|
||||
// CGCS2000 → WGS-84
|
||||
try {
|
||||
const result = unprojectToLngLat(
|
||||
parseFloat(x), parseFloat(y),
|
||||
zoneType.value, withZone.value, zoneNumber.value
|
||||
)
|
||||
return { lng: result.lng, lat: result.lat }
|
||||
} catch (e) {
|
||||
throw new Error(`投影反算失败: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WGS-84 to GCJ-02 for map display ───────────────
|
||||
function wgs84ToGcj02(lng, lat) {
|
||||
return gcoord.transform([lng, lat], gcoord.WGS84, gcoord.GCJ02)
|
||||
}
|
||||
|
||||
// ─── Add points to map ───────────────────────────────
|
||||
function addPointsToMap(newPoints) {
|
||||
newPoints.forEach((p, idx) => {
|
||||
const num = points.value.length - newPoints.length + idx + 1
|
||||
// Convert WGS-84 to GCJ-02 for map display
|
||||
const [gcjLng, gcjLat] = wgs84ToGcj02(p.lng, p.lat)
|
||||
|
||||
const marker = L.marker([gcjLat, gcjLng], {
|
||||
icon: L.divIcon({
|
||||
className: 'custom-number-marker',
|
||||
html: `<div class="marker-pin"><span>${num}</span></div>`,
|
||||
iconSize: [30, 40],
|
||||
iconAnchor: [15, 40],
|
||||
popupAnchor: [0, -40]
|
||||
})
|
||||
})
|
||||
|
||||
marker.bindPopup(`
|
||||
<div style="font-family: Inter, sans-serif; font-size: 13px; line-height: 1.6;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px; color: #6366f1;">
|
||||
📍 #${num} ${p.name || ''}
|
||||
</div>
|
||||
<div><b>WGS-84:</b> ${p.lng.toFixed(6)}, ${p.lat.toFixed(6)}</div>
|
||||
</div>
|
||||
`)
|
||||
|
||||
markerGroup.addLayer(marker)
|
||||
})
|
||||
|
||||
if (newPoints.length > 0) {
|
||||
fitMapToPoints()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Manual add ──────────────────────────────────────
|
||||
function addManualPoint() {
|
||||
if (!manualX.value || !manualY.value) {
|
||||
showMsg('请输入坐标值', true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const coord = toWgs84(manualX.value, manualY.value)
|
||||
|
||||
if (isNaN(coord.lng) || isNaN(coord.lat)) {
|
||||
showMsg('坐标格式不正确', true)
|
||||
return
|
||||
}
|
||||
|
||||
const pt = { lng: coord.lng, lat: coord.lat, name: manualName.value }
|
||||
points.value.push(pt)
|
||||
addPointsToMap([pt])
|
||||
|
||||
manualX.value = ''
|
||||
manualY.value = ''
|
||||
manualName.value = ''
|
||||
showMsg('已添加 1 个坐标点')
|
||||
} catch (e) {
|
||||
showMsg(e.message, true)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Batch parse ─────────────────────────────────────
|
||||
function parseBatchInput() {
|
||||
if (!batchText.value.trim()) {
|
||||
showMsg('请输入批量坐标数据', true)
|
||||
return
|
||||
}
|
||||
|
||||
const lines = batchText.value.trim().split('\n').filter(l => l.trim())
|
||||
const newPts = []
|
||||
const errors = []
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
const parts = line.trim().split(/[,\t\s]+/)
|
||||
if (parts.length < 2) {
|
||||
errors.push(`第${i + 1}行格式不正确`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const coord = toWgs84(parts[0], parts[1])
|
||||
if (isNaN(coord.lng) || isNaN(coord.lat)) {
|
||||
errors.push(`第${i + 1}行坐标无效`)
|
||||
return
|
||||
}
|
||||
newPts.push({
|
||||
lng: coord.lng,
|
||||
lat: coord.lat,
|
||||
name: parts[2] || ''
|
||||
})
|
||||
} catch (e) {
|
||||
errors.push(`第${i + 1}行: ${e.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (newPts.length > 0) {
|
||||
points.value.push(...newPts)
|
||||
addPointsToMap(newPts)
|
||||
showMsg(`已添加 ${newPts.length} 个坐标点${errors.length > 0 ? `,${errors.length} 行出错` : ''}`)
|
||||
} else {
|
||||
showMsg(`解析失败: ${errors[0] || '无有效数据'}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Excel handling ──────────────────────────────────
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileDrop(e) {
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) readExcelFile(file)
|
||||
}
|
||||
|
||||
function handleFileSelect(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file) readExcelFile(file)
|
||||
}
|
||||
|
||||
function readExcelFile(file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target.result)
|
||||
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) {
|
||||
showMsg('Excel 文件为空', true)
|
||||
return
|
||||
}
|
||||
|
||||
excelRawData.value = json
|
||||
excelColumns.value = Object.keys(json[0])
|
||||
// Auto-detect column mapping
|
||||
autoDetectColumns()
|
||||
showMsg(`已读取 ${json.length} 行数据,请设置列映射`)
|
||||
} catch (err) {
|
||||
showMsg(`读取文件失败: ${err.message}`, true)
|
||||
}
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
|
||||
function autoDetectColumns() {
|
||||
const cols = excelColumns.value.map(c => c.toLowerCase())
|
||||
const lngPatterns = ['经度', 'lon', 'lng', 'longitude', 'x', 'easting']
|
||||
const latPatterns = ['纬度', 'lat', 'latitude', 'y', 'northing']
|
||||
const namePatterns = ['名称', 'name', '地名', '标注', 'label', 'title']
|
||||
|
||||
for (const pat of lngPatterns) {
|
||||
const idx = cols.findIndex(c => c.includes(pat))
|
||||
if (idx >= 0) { mappingX.value = excelColumns.value[idx]; break }
|
||||
}
|
||||
for (const pat of latPatterns) {
|
||||
const idx = cols.findIndex(c => c.includes(pat))
|
||||
if (idx >= 0) { mappingY.value = excelColumns.value[idx]; break }
|
||||
}
|
||||
for (const pat of namePatterns) {
|
||||
const idx = cols.findIndex(c => c.includes(pat))
|
||||
if (idx >= 0) { mappingName.value = excelColumns.value[idx]; break }
|
||||
}
|
||||
}
|
||||
|
||||
function importExcelData() {
|
||||
if (!mappingX.value || !mappingY.value) {
|
||||
showMsg('请设置经度和纬度列映射', true)
|
||||
return
|
||||
}
|
||||
|
||||
const newPts = []
|
||||
const errors = []
|
||||
|
||||
excelRawData.value.forEach((row, i) => {
|
||||
const x = row[mappingX.value]
|
||||
const y = row[mappingY.value]
|
||||
const name = mappingName.value ? (row[mappingName.value] || '') : ''
|
||||
|
||||
if (x === undefined || y === undefined || x === '' || y === '') {
|
||||
errors.push(`第${i + 2}行坐标为空`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const coord = toWgs84(x, y)
|
||||
if (isNaN(coord.lng) || isNaN(coord.lat)) {
|
||||
errors.push(`第${i + 2}行坐标无效`)
|
||||
return
|
||||
}
|
||||
newPts.push({ lng: coord.lng, lat: coord.lat, name: String(name) })
|
||||
} catch (e) {
|
||||
errors.push(`第${i + 2}行: ${e.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (newPts.length > 0) {
|
||||
points.value.push(...newPts)
|
||||
addPointsToMap(newPts)
|
||||
showMsg(`已导入 ${newPts.length} 个坐标点${errors.length > 0 ? `,${errors.length} 行出错` : ''}`)
|
||||
// Reset excel state
|
||||
excelColumns.value = []
|
||||
excelRawData.value = []
|
||||
} else {
|
||||
showMsg(`导入失败: ${errors[0] || '无有效数据'}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Map operations ──────────────────────────────────
|
||||
function fitMapToPoints() {
|
||||
if (points.value.length === 0 || !map) return
|
||||
|
||||
const gcjPoints = points.value.map(p => {
|
||||
const [gcjLng, gcjLat] = wgs84ToGcj02(p.lng, p.lat)
|
||||
return [gcjLat, gcjLng]
|
||||
})
|
||||
|
||||
if (gcjPoints.length === 1) {
|
||||
map.setView(gcjPoints[0], 14)
|
||||
} else {
|
||||
const bounds = L.latLngBounds(gcjPoints)
|
||||
map.fitBounds(bounds, { padding: [40, 40] })
|
||||
}
|
||||
}
|
||||
|
||||
function zoomToPoint(p) {
|
||||
if (!map) return
|
||||
const [gcjLng, gcjLat] = wgs84ToGcj02(p.lng, p.lat)
|
||||
map.setView([gcjLat, gcjLng], 15)
|
||||
}
|
||||
|
||||
function removePoint(idx) {
|
||||
points.value.splice(idx, 1)
|
||||
refreshMarkers()
|
||||
}
|
||||
|
||||
function clearPoints() {
|
||||
points.value = []
|
||||
markerGroup.clearLayers()
|
||||
showMsg('已清空所有坐标点')
|
||||
}
|
||||
|
||||
function refreshMarkers() {
|
||||
markerGroup.clearLayers()
|
||||
addPointsToMap([...points.value])
|
||||
}
|
||||
|
||||
// ─── Export ──────────────────────────────────────────
|
||||
function exportExcel() {
|
||||
if (points.value.length === 0) {
|
||||
showMsg('没有可导出的数据', true)
|
||||
return
|
||||
}
|
||||
|
||||
const rows = points.value.map((p, i) => ({
|
||||
'序号': i + 1,
|
||||
'名称': p.name || '',
|
||||
'WGS-84经度': p.lng.toFixed(6),
|
||||
'WGS-84纬度': p.lat.toFixed(6)
|
||||
}))
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(rows)
|
||||
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} 个坐标点`)
|
||||
}
|
||||
|
||||
// ─── Messages ────────────────────────────────────────
|
||||
function showMsg(msg, isError = false) {
|
||||
message.value = msg
|
||||
msgType.value = isError ? 'error' : 'success'
|
||||
setTimeout(() => { message.value = '' }, 3500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plotter-container {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--spacing-md);
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.plotter-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Input Panel */
|
||||
.input-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
height: fit-content;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.proj-settings {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.radio-item input {
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.input-tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-xs);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.manual-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.manual-form .input-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Textarea */
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* File Upload */
|
||||
.file-upload-area {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.file-upload-area:hover {
|
||||
border-color: var(--primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Column Mapping */
|
||||
.column-mapping {
|
||||
margin-top: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.column-mapping h4 {
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.mapping-row .input-label {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.mapping-row .input {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Map */
|
||||
.map-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.map-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#plotter-map {
|
||||
width: 100%;
|
||||
height: 510px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
/* Points Panel */
|
||||
.points-panel {
|
||||
max-height: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.points-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.points-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
}
|
||||
|
||||
.points-table-wrapper {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.points-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.points-table th {
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
text-align: left;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.points-table td {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.point-row {
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.point-row:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
color: var(--danger);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* Custom Marker */
|
||||
:deep(.custom-number-marker) {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.marker-pin) {
|
||||
width: 28px;
|
||||
height: 36px;
|
||||
background: var(--gradient-primary);
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
:deep(.marker-pin span) {
|
||||
transform: rotate(45deg);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
font-family: Inter, sans-serif;
|
||||
}
|
||||
|
||||
/* Message Toast */
|
||||
.message-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
padding: var(--spacing-md) var(--spacing-xl);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
animation: slideInRight 0.3s ease;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.message-toast.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.message-toast.error {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
border-left: 4px solid #c33;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { opacity: 0; transform: translateX(40px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.plotter-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.input-panel {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
#plotter-map {
|
||||
height: 350px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
msg: String,
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
960
src/components/MapInteraction.vue
Normal file
960
src/components/MapInteraction.vue
Normal file
@@ -0,0 +1,960 @@
|
||||
<template>
|
||||
<div class="container map-tool-container">
|
||||
<div class="tool-header">
|
||||
<router-link to="/" class="back-link">← 返回首页</router-link>
|
||||
<h2>🗺️ 图上量测与拾取</h2>
|
||||
<p>在地图上点选拾取坐标、线选量距、面选量面积,实时显示地理与投影坐标</p>
|
||||
</div>
|
||||
|
||||
<div class="map-layout">
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar card">
|
||||
<h3 class="card-title">工具栏</h3>
|
||||
<div class="tool-buttons">
|
||||
<button
|
||||
v-for="tool in toolModes"
|
||||
:key="tool.mode"
|
||||
:class="['btn', 'tool-btn', { active: currentMode === tool.mode }]"
|
||||
@click="setMode(tool.mode)"
|
||||
>
|
||||
<span class="tool-btn-icon">{{ tool.icon }}</span>
|
||||
<span class="tool-btn-label">{{ tool.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="projection-settings">
|
||||
<label class="input-label">投影分带</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-item">
|
||||
<input type="radio" v-model="zoneType" :value="3" />
|
||||
<span>3°带</span>
|
||||
</label>
|
||||
<label class="radio-item">
|
||||
<input type="radio" v-model="zoneType" :value="6" />
|
||||
<span>6°带</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-secondary clear-btn" @click="clearAll">
|
||||
🗑️ 清除全部
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
<div class="map-wrapper card">
|
||||
<div id="map-container" ref="mapRef"></div>
|
||||
|
||||
<!-- Floating coordinate panel -->
|
||||
<div class="coord-panel" v-if="cursorCoord">
|
||||
<div class="coord-panel-title">实时坐标</div>
|
||||
<div class="coord-row">
|
||||
<span class="coord-label">GCJ-02</span>
|
||||
<span class="coord-value">{{ cursorCoord.gcjLng }}, {{ cursorCoord.gcjLat }}</span>
|
||||
</div>
|
||||
<div class="coord-row">
|
||||
<span class="coord-label">WGS-84</span>
|
||||
<span class="coord-value">{{ cursorCoord.wgsLng }}, {{ cursorCoord.wgsLat }}</span>
|
||||
</div>
|
||||
<div class="coord-row">
|
||||
<span class="coord-label">CGCS2000</span>
|
||||
<span class="coord-value">E{{ cursorCoord.easting }}, N{{ cursorCoord.northing }}</span>
|
||||
</div>
|
||||
<div class="coord-row">
|
||||
<span class="coord-label">带号</span>
|
||||
<span class="coord-value">{{ cursorCoord.zone }}带 (中央{{ cursorCoord.meridian }}°)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current mode hint -->
|
||||
<div class="mode-hint">
|
||||
<span class="mode-badge" :class="currentMode">
|
||||
{{ currentModeHint }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Panel -->
|
||||
<div v-if="results.length > 0" class="results-panel card">
|
||||
<div class="results-header">
|
||||
<h3 class="card-title">📋 拾取与量测结果 ({{ results.length }})</h3>
|
||||
<div class="results-actions">
|
||||
<button class="btn btn-secondary" @click="copyResults">📋 复制</button>
|
||||
<button class="btn btn-secondary" @click="clearResults">🗑️ 清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-list">
|
||||
<div
|
||||
v-for="(r, i) in results"
|
||||
:key="i"
|
||||
class="result-item"
|
||||
:class="r.type"
|
||||
>
|
||||
<div class="result-item-header">
|
||||
<span class="result-type-badge" :class="r.type">
|
||||
{{ r.type === 'point' ? '📍 点' : r.type === 'line' ? '📏 线' : '📐 面' }}
|
||||
</span>
|
||||
<span class="result-index">#{{ i + 1 }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Point result -->
|
||||
<div v-if="r.type === 'point'" class="result-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">WGS-84</span>
|
||||
<span class="detail-value mono">{{ r.wgsLng }}, {{ r.wgsLat }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">CGCS2000</span>
|
||||
<span class="detail-value mono">E{{ r.easting }}, N{{ r.northing }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">投影信息</span>
|
||||
<span class="detail-value">{{ r.zone }}带, 中央子午线 {{ r.meridian }}°</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Line result -->
|
||||
<div v-if="r.type === 'line'" class="result-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">总长度</span>
|
||||
<span class="detail-value result-highlight">{{ r.distanceText }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">节点数</span>
|
||||
<span class="detail-value">{{ r.pointCount }} 个</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Polygon result -->
|
||||
<div v-if="r.type === 'polygon'" class="result-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">面积</span>
|
||||
<span class="detail-value result-highlight">{{ r.areaText }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">周长</span>
|
||||
<span class="detail-value">{{ r.perimeterText }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">节点数</span>
|
||||
<span class="detail-value">{{ r.pointCount }} 个</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-if="message" :class="['message-toast', messageType]">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { gcj02ToWgs84 } from '../utils/amap'
|
||||
import { projectToPlane } from '../utils/proj'
|
||||
|
||||
// ─── State ────────────────────────────────────────────
|
||||
const mapRef = ref(null)
|
||||
let map = null
|
||||
|
||||
const currentMode = ref('point')
|
||||
const zoneType = ref(3)
|
||||
const cursorCoord = ref(null)
|
||||
const results = ref([])
|
||||
const message = ref('')
|
||||
const messageType = ref('success')
|
||||
|
||||
// Drawing state
|
||||
let drawingPoints = []
|
||||
let drawingLayer = null
|
||||
let drawingMarkers = []
|
||||
let tempLine = null
|
||||
|
||||
const toolModes = [
|
||||
{ mode: 'point', icon: '📍', label: '点选拾取' },
|
||||
{ mode: 'line', icon: '📏', label: '线选量距' },
|
||||
{ mode: 'polygon', icon: '📐', label: '面选量面' }
|
||||
]
|
||||
|
||||
const currentModeHint = computed(() => {
|
||||
switch (currentMode.value) {
|
||||
case 'point': return '点击地图拾取坐标'
|
||||
case 'line': return '单击添加节点,双击结束量距'
|
||||
case 'polygon': return '单击添加节点,双击闭合量面'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Map Initialization ──────────────────────────────
|
||||
onMounted(() => {
|
||||
initMap()
|
||||
})
|
||||
|
||||
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('click', onMapClick)
|
||||
map.on('dblclick', onMapDoubleClick)
|
||||
}
|
||||
|
||||
// ─── Coordinate Conversion ───────────────────────────
|
||||
function convertCoord(latlng) {
|
||||
const gcjLng = parseFloat(latlng.lng.toFixed(6))
|
||||
const gcjLat = parseFloat(latlng.lat.toFixed(6))
|
||||
const [wgsLng, wgsLat] = gcj02ToWgs84(gcjLng, gcjLat)
|
||||
const proj = projectToPlane(wgsLng, wgsLat, zoneType.value, false)
|
||||
|
||||
return {
|
||||
gcjLng,
|
||||
gcjLat,
|
||||
wgsLng: parseFloat(wgsLng.toFixed(6)),
|
||||
wgsLat: parseFloat(wgsLat.toFixed(6)),
|
||||
easting: parseFloat(proj.easting.toFixed(3)),
|
||||
northing: parseFloat(proj.northing.toFixed(3)),
|
||||
zone: proj.zone,
|
||||
meridian: proj.meridian
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mouse Move ──────────────────────────────────────
|
||||
function onMouseMove(e) {
|
||||
cursorCoord.value = convertCoord(e.latlng)
|
||||
|
||||
// Update temp line while drawing
|
||||
if ((currentMode.value === 'line' || currentMode.value === 'polygon') && drawingPoints.length > 0) {
|
||||
const pts = [...drawingPoints, [e.latlng.lat, e.latlng.lng]]
|
||||
if (tempLine) {
|
||||
tempLine.setLatLngs(pts)
|
||||
} else {
|
||||
tempLine = L.polyline(pts, {
|
||||
color: currentMode.value === 'line' ? '#6366f1' : '#f59e0b',
|
||||
weight: 2,
|
||||
dashArray: '6, 8',
|
||||
opacity: 0.7
|
||||
}).addTo(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Map Click ───────────────────────────────────────
|
||||
function onMapClick(e) {
|
||||
const coord = convertCoord(e.latlng)
|
||||
|
||||
if (currentMode.value === 'point') {
|
||||
handlePointPick(e.latlng, coord)
|
||||
} else if (currentMode.value === 'line' || currentMode.value === 'polygon') {
|
||||
handleDrawingClick(e.latlng, coord)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Point Pick ──────────────────────────────────────
|
||||
function handlePointPick(latlng, coord) {
|
||||
// Add marker
|
||||
const marker = L.circleMarker([latlng.lat, latlng.lng], {
|
||||
radius: 7,
|
||||
fillColor: '#6366f1',
|
||||
color: '#4f46e5',
|
||||
weight: 2,
|
||||
fillOpacity: 0.9
|
||||
}).addTo(map)
|
||||
|
||||
marker.bindPopup(`
|
||||
<div style="font-family: Inter, sans-serif; font-size: 13px; line-height: 1.6;">
|
||||
<div style="font-weight: 600; margin-bottom: 4px; color: #4f46e5;">📍 拾取坐标</div>
|
||||
<div><b>WGS-84:</b> ${coord.wgsLng}, ${coord.wgsLat}</div>
|
||||
<div><b>CGCS2000:</b> E${coord.easting}, N${coord.northing}</div>
|
||||
<div><b>投影:</b> ${coord.zone}带, 中央${coord.meridian}°</div>
|
||||
</div>
|
||||
`).openPopup()
|
||||
|
||||
results.value.push({
|
||||
type: 'point',
|
||||
...coord
|
||||
})
|
||||
|
||||
showMessage(`已拾取坐标: ${coord.wgsLng}, ${coord.wgsLat}`)
|
||||
}
|
||||
|
||||
// ─── Drawing Click ───────────────────────────────────
|
||||
function handleDrawingClick(latlng) {
|
||||
drawingPoints.push([latlng.lat, latlng.lng])
|
||||
|
||||
// Add vertex marker
|
||||
const vertex = L.circleMarker([latlng.lat, latlng.lng], {
|
||||
radius: 5,
|
||||
fillColor: currentMode.value === 'line' ? '#6366f1' : '#f59e0b',
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
fillOpacity: 1
|
||||
}).addTo(map)
|
||||
drawingMarkers.push(vertex)
|
||||
|
||||
// Update drawing layer
|
||||
if (drawingPoints.length > 1) {
|
||||
if (drawingLayer) {
|
||||
drawingLayer.setLatLngs(drawingPoints)
|
||||
} else {
|
||||
const color = currentMode.value === 'line' ? '#6366f1' : '#f59e0b'
|
||||
drawingLayer = L.polyline(drawingPoints, {
|
||||
color: color,
|
||||
weight: 3,
|
||||
opacity: 0.9
|
||||
}).addTo(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Double Click (finish drawing) ───────────────────
|
||||
function onMapDoubleClick() {
|
||||
if (currentMode.value === 'line' && drawingPoints.length >= 2) {
|
||||
finishLine()
|
||||
} else if (currentMode.value === 'polygon' && drawingPoints.length >= 3) {
|
||||
finishPolygon()
|
||||
}
|
||||
}
|
||||
|
||||
function finishLine() {
|
||||
// Calculate distance using haversine via turf-like approach
|
||||
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])
|
||||
return [wLat, wLng]
|
||||
})
|
||||
|
||||
// Recalculate with WGS-84 coords
|
||||
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
|
||||
? `${(wgsDist / 1000).toFixed(3)} km`
|
||||
: `${wgsDist.toFixed(2)} m`
|
||||
|
||||
// Style the finished line
|
||||
if (drawingLayer) {
|
||||
drawingLayer.setStyle({ weight: 4, opacity: 1, dashArray: null })
|
||||
|
||||
drawingLayer.bindPopup(`
|
||||
<div style="font-family: Inter, sans-serif; font-size: 13px;">
|
||||
<div style="font-weight: 600; color: #4f46e5;">📏 量测距离</div>
|
||||
<div style="font-size: 16px; font-weight: 700; color: #6366f1; margin: 4px 0;">${distanceText}</div>
|
||||
<div>节点数: ${drawingPoints.length}</div>
|
||||
</div>
|
||||
`).openPopup()
|
||||
}
|
||||
|
||||
results.value.push({
|
||||
type: 'line',
|
||||
distanceText,
|
||||
distance: wgsDist,
|
||||
pointCount: drawingPoints.length
|
||||
})
|
||||
|
||||
showMessage(`量测完成: ${distanceText}`)
|
||||
resetDrawing()
|
||||
}
|
||||
|
||||
function finishPolygon() {
|
||||
// Close the polygon
|
||||
const closedPts = [...drawingPoints, drawingPoints[0]]
|
||||
|
||||
// Remove polyline and create polygon
|
||||
if (drawingLayer) {
|
||||
map.removeLayer(drawingLayer)
|
||||
}
|
||||
if (tempLine) {
|
||||
map.removeLayer(tempLine)
|
||||
tempLine = null
|
||||
}
|
||||
|
||||
const color = '#f59e0b'
|
||||
const polygon = L.polygon(closedPts, {
|
||||
color: color,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.15,
|
||||
weight: 3
|
||||
}).addTo(map)
|
||||
|
||||
// Convert to WGS-84 for accurate measurement
|
||||
const wgsPoints = drawingPoints.map(p => {
|
||||
const [wLng, wLat] = gcj02ToWgs84(p[1], p[0])
|
||||
return [wLat, wLng]
|
||||
})
|
||||
|
||||
// Calculate area using spherical excess formula
|
||||
const area = sphericalPolygonArea(wgsPoints)
|
||||
const areaText = formatArea(area)
|
||||
|
||||
// Calculate perimeter
|
||||
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
|
||||
? `${(perimeter / 1000).toFixed(3)} km`
|
||||
: `${perimeter.toFixed(2)} m`
|
||||
|
||||
polygon.bindPopup(`
|
||||
<div style="font-family: Inter, sans-serif; font-size: 13px;">
|
||||
<div style="font-weight: 600; color: #f59e0b;">📐 量测面积</div>
|
||||
<div style="font-size: 16px; font-weight: 700; color: #f59e0b; margin: 4px 0;">${areaText}</div>
|
||||
<div>周长: ${perimeterText}</div>
|
||||
<div>节点数: ${drawingPoints.length}</div>
|
||||
</div>
|
||||
`).openPopup()
|
||||
|
||||
results.value.push({
|
||||
type: 'polygon',
|
||||
areaText,
|
||||
area,
|
||||
perimeterText,
|
||||
perimeter,
|
||||
pointCount: drawingPoints.length
|
||||
})
|
||||
|
||||
showMessage(`量测完成: ${areaText}`)
|
||||
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 ─────────────────────────────────────
|
||||
function formatArea(sqm) {
|
||||
if (sqm > 1e6) {
|
||||
return `${(sqm / 1e6).toFixed(4)} km² (${(sqm / 666.667).toFixed(2)} 亩)`
|
||||
} else if (sqm > 10000) {
|
||||
return `${sqm.toFixed(1)} m² (${(sqm / 666.667).toFixed(2)} 亩)`
|
||||
} else {
|
||||
return `${sqm.toFixed(2)} m²`
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mode Switch ─────────────────────────────────────
|
||||
function setMode(mode) {
|
||||
if (drawingPoints.length > 0) {
|
||||
resetDrawing()
|
||||
}
|
||||
currentMode.value = mode
|
||||
|
||||
if (map) {
|
||||
if (mode === 'point') {
|
||||
map.getContainer().style.cursor = 'crosshair'
|
||||
} else {
|
||||
map.getContainer().style.cursor = 'crosshair'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reset Drawing ───────────────────────────────────
|
||||
function resetDrawing() {
|
||||
drawingPoints = []
|
||||
drawingMarkers.forEach(m => map.removeLayer(m))
|
||||
drawingMarkers = []
|
||||
if (tempLine) {
|
||||
map.removeLayer(tempLine)
|
||||
tempLine = null
|
||||
}
|
||||
drawingLayer = null
|
||||
}
|
||||
|
||||
// ─── Clear All ───────────────────────────────────────
|
||||
function clearAll() {
|
||||
resetDrawing()
|
||||
results.value = []
|
||||
|
||||
// Remove all layers except tile layer
|
||||
map.eachLayer(layer => {
|
||||
if (!(layer instanceof L.TileLayer)) {
|
||||
map.removeLayer(layer)
|
||||
}
|
||||
})
|
||||
|
||||
showMessage('已清除所有标注和结果')
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
results.value = []
|
||||
showMessage('已清空结果列表')
|
||||
}
|
||||
|
||||
// ─── Copy Results ────────────────────────────────────
|
||||
function copyResults() {
|
||||
const text = results.value.map((r, i) => {
|
||||
if (r.type === 'point') {
|
||||
return `#${i + 1} 点: WGS-84(${r.wgsLng}, ${r.wgsLat}), CGCS2000(E${r.easting}, N${r.northing}), ${r.zone}带`
|
||||
} else if (r.type === 'line') {
|
||||
return `#${i + 1} 线: ${r.distanceText}, ${r.pointCount}个节点`
|
||||
} else {
|
||||
return `#${i + 1} 面: ${r.areaText}, 周长${r.perimeterText}, ${r.pointCount}个节点`
|
||||
}
|
||||
}).join('\n')
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showMessage('结果已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
showMessage('复制失败,请手动选择复制', true)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Messages ────────────────────────────────────────
|
||||
function showMessage(msg, isError = false) {
|
||||
message.value = msg
|
||||
messageType.value = isError ? 'error' : 'success'
|
||||
setTimeout(() => { message.value = '' }, 3000)
|
||||
}
|
||||
|
||||
// ─── Watch zone type change ──────────────────────────
|
||||
watch(zoneType, () => {
|
||||
if (cursorCoord.value) {
|
||||
// Recalculate current cursor projection
|
||||
const proj = projectToPlane(cursorCoord.value.wgsLng, cursorCoord.value.wgsLat, zoneType.value, false)
|
||||
cursorCoord.value.easting = parseFloat(proj.easting.toFixed(3))
|
||||
cursorCoord.value.northing = parseFloat(proj.northing.toFixed(3))
|
||||
cursorCoord.value.zone = proj.zone
|
||||
cursorCoord.value.meridian = proj.meridian
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-tool-container {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--spacing-md);
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.map-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.tool-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
border-color: var(--primary-light);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.tool-btn.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.tool-btn-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.tool-btn-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.projection-settings {
|
||||
margin-top: var(--spacing-sm);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.radio-item input[type="radio"] {
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Map */
|
||||
.map-wrapper {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#map-container {
|
||||
width: 100%;
|
||||
height: 550px;
|
||||
border-radius: var(--border-radius);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Coordinate Panel */
|
||||
.coord-panel {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
background: rgba(15, 23, 42, 0.88);
|
||||
backdrop-filter: blur(12px);
|
||||
color: #f1f5f9;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 12px;
|
||||
z-index: 1000;
|
||||
min-width: 280px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.coord-panel-title {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #818cf8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.coord-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.coord-label {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
min-width: 65px;
|
||||
}
|
||||
|
||||
.coord-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 11.5px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Mode hint */
|
||||
.mode-hint {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.mode-badge.point {
|
||||
background: rgba(99, 102, 241, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-badge.line {
|
||||
background: rgba(99, 102, 241, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-badge.polygon {
|
||||
background: rgba(245, 158, 11, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Results Panel */
|
||||
.results-panel {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.result-item {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--spacing-md);
|
||||
border-left: 4px solid var(--primary);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.result-item.line {
|
||||
border-left-color: var(--primary);
|
||||
}
|
||||
|
||||
.result-item.polygon {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.result-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.result-type-badge {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-type-badge.point { color: var(--primary); }
|
||||
.result-type-badge.line { color: var(--primary); }
|
||||
.result-type-badge.polygon { color: var(--accent); }
|
||||
|
||||
.result-index {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
min-width: 65px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detail-value.mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.result-highlight {
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* Message Toast */
|
||||
.message-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
padding: var(--spacing-md) var(--spacing-xl);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
animation: slideInRight 0.3s ease;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.message-toast.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.message-toast.error {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
border-left: 4px solid #c33;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.map-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-buttons {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#map-container {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.coord-panel {
|
||||
min-width: 240px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,8 @@ 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 = [
|
||||
{
|
||||
@@ -48,6 +50,16 @@ const routes = [
|
||||
path: '/amap-search',
|
||||
name: 'AmapSearch',
|
||||
component: AmapSearchTool
|
||||
},
|
||||
{
|
||||
path: '/map-interaction',
|
||||
name: 'MapInteraction',
|
||||
component: MapInteraction
|
||||
},
|
||||
{
|
||||
path: '/coordinate-plotter',
|
||||
name: 'CoordinatePlotter',
|
||||
component: CoordinatePlotter
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -60,6 +60,18 @@ const tools = ref([
|
||||
icon: '🗺️',
|
||||
description: '通过高德API搜索POI,获取坐标并导出',
|
||||
path: '/amap-search'
|
||||
},
|
||||
{
|
||||
name: '图上量测与拾取',
|
||||
icon: '🗺️',
|
||||
description: '在地图上点选坐标、量测距离和面积,实时显示地理与投影坐标',
|
||||
path: '/map-interaction'
|
||||
},
|
||||
{
|
||||
name: '坐标展点',
|
||||
icon: '📍',
|
||||
description: '将坐标批量展绘到地图上,支持手动输入、批量粘贴和Excel导入',
|
||||
path: '/coordinate-plotter'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -4,5 +4,11 @@ import vue from '@vitejs/plugin-vue'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0', // 允许局域网访问
|
||||
port: 5173, // 端口号,可自定义
|
||||
strictPort: false, // 端口被占用时自动尝试下一个
|
||||
open: false, // 启动时不自动打开浏览器
|
||||
},
|
||||
base: '/geo-tools/',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user