1. 新增图上量测与拾取、坐标展点两个地图工具页面 2. 安装leaflet依赖并配置vite开发服务器允许局域网访问 3. 更新首页工具列表,新增两个工具入口 4. 优化坐标转换器默认参数和文件读取方式 5. 调整面积计算精度,删除无用的HelloWorld组件
961 lines
28 KiB
Vue
961 lines
28 KiB
Vue
<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>
|