feat: add map interaction and coordinate plotter tools, optimize dev config

1. 新增图上量测与拾取、坐标展点两个地图工具页面
2. 安装leaflet依赖并配置vite开发服务器允许局域网访问
3. 更新首页工具列表,新增两个工具入口
4. 优化坐标转换器默认参数和文件读取方式
5. 调整面积计算精度,删除无用的HelloWorld组件
This commit is contained in:
2026-06-08 09:37:20 +08:00
parent 8bf55b6716
commit be48495dcf
10 changed files with 1933 additions and 54 deletions

7
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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) => {

View File

@@ -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>

View 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: '&copy; 高德地图'
}).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>

View File

@@ -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>

View 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: '&copy; 高德地图'
}).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)}`
}
}
// ─── 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>

View File

@@ -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
}
]

View File

@@ -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>

View File

@@ -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/',
})