Files
geo-tools/src/components/MapInteraction.vue
missum be48495dcf feat: add map interaction and coordinate plotter tools, optimize dev config
1. 新增图上量测与拾取、坐标展点两个地图工具页面
2. 安装leaflet依赖并配置vite开发服务器允许局域网访问
3. 更新首页工具列表,新增两个工具入口
4. 优化坐标转换器默认参数和文件读取方式
5. 调整面积计算精度,删除无用的HelloWorld组件
2026-06-08 09:37:20 +08:00

961 lines
28 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="container 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>