|
|
@@ -1,98 +1,129 @@
|
|
|
<!--路线规划辅助-->
|
|
|
<template>
|
|
|
- <div class="prototype-container">
|
|
|
- <!-- 头部 -->
|
|
|
- <div class="header">
|
|
|
- <div class="logo">
|
|
|
- <h2>🗺️ 智能应急路线规划系统</h2>
|
|
|
- <p>基于GIS · 实时绕行分析 | 最短路径 · 最快路径 | 应急处置人员 & 社会公众出行</p>
|
|
|
- </div>
|
|
|
- <div class="badge-gis">📍 实时GIS引擎 · 智能路径计算</div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div class="main-layout">
|
|
|
- <!-- 左侧控制面板 -->
|
|
|
- <div class="info-panel">
|
|
|
- <!-- 突发事件地点设置 -->
|
|
|
- <div class="card">
|
|
|
- <div class="card-header">🚨 突发事件定位</div>
|
|
|
- <div class="card-body">
|
|
|
- <div class="form-group">
|
|
|
- <label class="form-label">事件地点</label>
|
|
|
- <select v-model="selectedEventLocation" class="form-select" @change="refreshAll">
|
|
|
- <option value="化工园区">化工园区(经度:118.5,纬度:31.2)</option>
|
|
|
- <option value="城南枢纽">城南枢纽(经度:118.45,纬度:31.15)</option>
|
|
|
- <option value="城西隧道">城西隧道(经度:118.38,纬度:31.22)</option>
|
|
|
- <option value="滨海大道">滨海大道(经度:118.62,纬度:31.08)</option>
|
|
|
- </select>
|
|
|
+ <div class="app-container">
|
|
|
+ <div class="route-layout">
|
|
|
+ <!-- 左侧信息面板 -->
|
|
|
+ <div class="left-panel">
|
|
|
+ <!-- 突发事件定位 -->
|
|
|
+ <el-card shadow="never" class="panel-card">
|
|
|
+ <template #header>
|
|
|
+ <div class="card-title">
|
|
|
+ <el-icon><Location /></el-icon>
|
|
|
+ <span>突发事件定位</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <div class="event-location">
|
|
|
+ <div class="loc-row">
|
|
|
+ <span class="loc-label">事件地点</span>
|
|
|
+ <el-tag type="danger" effect="dark">{{ selectedEventLocation }}</el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="loc-row">
|
|
|
+ <span class="loc-label">经度</span>
|
|
|
+ <span class="loc-value">{{ eventLocations[selectedEventLocation].lng }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="loc-row">
|
|
|
+ <span class="loc-label">纬度</span>
|
|
|
+ <span class="loc-value">{{ eventLocations[selectedEventLocation].lat }}</span>
|
|
|
</div>
|
|
|
- <button @click="refreshAll" class="btn-primary">🔄 分析路线 & 刷新GIS</button>
|
|
|
</div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 值守/指挥人员位置 -->
|
|
|
- <div class="card">
|
|
|
- <div class="card-header">👥 值守人员 & 指挥人员</div>
|
|
|
- <div class="card-body">
|
|
|
- <div class="person-list">
|
|
|
- <div v-for="person in personnel" :key="person.id" class="person-item">
|
|
|
- <span><strong>{{ person.name }}</strong> ({{ person.role }})</span>
|
|
|
- <span class="role-tag">📍 {{ person.position }}</span>
|
|
|
- </div>
|
|
|
+ <el-select v-model="selectedEventLocation" placeholder="选择事件地点" style="width:100%;margin-bottom:12px" @change="refreshAll">
|
|
|
+ <el-option v-for="(loc, key) in eventLocations" :key="key" :label="key" :value="key" />
|
|
|
+ </el-select>
|
|
|
+ <el-button type="primary" style="width:100%" @click="refreshAll">
|
|
|
+ <el-icon><Position /></el-icon> 分析路线 & 跳转GIS
|
|
|
+ </el-button>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 值守/指挥人员 -->
|
|
|
+ <el-card shadow="never" class="panel-card">
|
|
|
+ <template #header>
|
|
|
+ <div class="card-title">
|
|
|
+ <el-icon><User /></el-icon>
|
|
|
+ <span>值守人员 & 指挥人员</span>
|
|
|
</div>
|
|
|
- <div style="font-size:0.65rem; margin-top:8px; color:#6e95b0;">
|
|
|
- 💡 系统基于人员当前位置自动分析绕行路线
|
|
|
+ </template>
|
|
|
+ <div class="person-list">
|
|
|
+ <div v-for="person in personnel" :key="person.id" class="person-item">
|
|
|
+ <div class="person-info">
|
|
|
+ <span class="person-name">{{ person.name }}</span>
|
|
|
+ <el-tag :type="person.role === '指挥人员' ? 'warning' : 'primary'" size="small">{{ person.role }}</el-tag>
|
|
|
+ </div>
|
|
|
+ <el-button size="small" text type="primary">{{ person.position }}</el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 路径规划结果 -->
|
|
|
- <div class="card">
|
|
|
- <div class="card-header">🛣️ 智能绕行路线推荐</div>
|
|
|
- <div class="card-body">
|
|
|
- <div class="route-result">
|
|
|
- <div
|
|
|
- v-for="route in displayRoutes"
|
|
|
- :key="route.type"
|
|
|
- class="route-item"
|
|
|
- :class="{ active: activeRouteType === route.type }"
|
|
|
- @click="selectRoute(route)"
|
|
|
- >
|
|
|
- <div class="route-name">
|
|
|
- <span>{{ route.icon }} {{ route.name }}</span>
|
|
|
- <span class="badge-dist">{{ route.metricValue }}</span>
|
|
|
- </div>
|
|
|
- <div class="route-detail">{{ route.pathDisplay }}</div>
|
|
|
- <div class="route-meta">
|
|
|
- 🚗 {{ route.estimateTime }}
|
|
|
- </div>
|
|
|
+ <div class="person-tip">系统根据人员当前位置自动分析最优路线</div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 智能绕行路线推荐 -->
|
|
|
+ <el-card shadow="never" class="panel-card">
|
|
|
+ <template #header>
|
|
|
+ <div class="card-title">
|
|
|
+ <el-icon><Guide /></el-icon>
|
|
|
+ <span>智能绕行路线推荐</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <div class="route-list">
|
|
|
+ <!-- 最短路径 -->
|
|
|
+ <div
|
|
|
+ class="route-item"
|
|
|
+ :class="{ active: activeRouteType === 'shortest' }"
|
|
|
+ @click="selectRoute('shortest')"
|
|
|
+ >
|
|
|
+ <div class="route-head">
|
|
|
+ <span class="route-name">最短路径(距离优先)</span>
|
|
|
+ <el-tag size="small" type="success">{{ displayRoutes.shortest?.distance || '--' }} km</el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="route-path">{{ displayRoutes.shortest?.pathDisplay || '--' }}</div>
|
|
|
+ <div class="route-meta">
|
|
|
+ <span v-if="displayRoutes.shortest">预估耗时 {{ displayRoutes.shortest.estimateTime }}分钟,途经{{ displayRoutes.shortest.viaArea }}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div style="margin-top: 12px;">
|
|
|
- <button @click="showPublicRoute" class="btn-outline" style="width:100%;">
|
|
|
- 👥 展示社会公众绕行方案
|
|
|
- </button>
|
|
|
+ <!-- 最快路径 -->
|
|
|
+ <div
|
|
|
+ class="route-item"
|
|
|
+ :class="{ active: activeRouteType === 'fastest' }"
|
|
|
+ @click="selectRoute('fastest')"
|
|
|
+ >
|
|
|
+ <div class="route-head">
|
|
|
+ <span class="route-name">最快路径(时间优先)</span>
|
|
|
+ <el-tag size="small" type="warning">{{ displayRoutes.fastest?.time || '--' }} min</el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="route-path">{{ displayRoutes.fastest?.pathDisplay || '--' }}</div>
|
|
|
+ <div class="route-meta">
|
|
|
+ <span v-if="displayRoutes.fastest">避开拥堵,节省约{{ displayRoutes.fastest.savePercent }}%时间</span>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
+ </el-card>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 右侧GIS地图展示区 -->
|
|
|
- <div class="map-panel">
|
|
|
- <div class="gis-container">
|
|
|
- <svg
|
|
|
- ref="svgMap"
|
|
|
- class="map-svg"
|
|
|
- viewBox="0 0 800 550"
|
|
|
- preserveAspectRatio="xMidYMid meet"
|
|
|
+ <!-- 右侧地图区域 -->
|
|
|
+ <div class="right-panel">
|
|
|
+ <div class="map-container">
|
|
|
+ <!-- 地名标签 -->
|
|
|
+ <div v-for="(pos, key) in mapLabels" :key="key" class="map-label" :style="{ left: pos.x + '%', top: pos.y + '%' }">
|
|
|
+ {{ key }}
|
|
|
+ </div>
|
|
|
+ <!-- 路线连接线 -->
|
|
|
+ <div v-for="(line, idx) in mapLines" :key="'line-'+idx" class="map-line" :style="line.style" :class="line.type" />
|
|
|
+ <!-- 模拟标记点 -->
|
|
|
+ <div
|
|
|
+ v-for="marker in mapMarkers"
|
|
|
+ :key="marker.id"
|
|
|
+ class="map-marker"
|
|
|
+ :class="marker.type"
|
|
|
+ :style="{ left: marker.x + '%', top: marker.y + '%' }"
|
|
|
>
|
|
|
- <!-- 地图内容动态绘制 -->
|
|
|
- </svg>
|
|
|
+ <div class="marker-dot"></div>
|
|
|
+ <div class="marker-pulse" v-if="marker.type === 'event'"></div>
|
|
|
+ <div class="marker-label">{{ marker.name }}</div>
|
|
|
+ </div>
|
|
|
+ <!-- 图例 -->
|
|
|
<div class="map-legend">
|
|
|
- <div><span class="legend-color" style="background:#e74c3c;"></span> 突发事件点</div>
|
|
|
- <div><span class="legend-color" style="background:#3498db;"></span> 值守/指挥人员</div>
|
|
|
- <div><span class="legend-color" style="background:#e07c3c;"></span> 推荐路线</div>
|
|
|
- <div><span class="legend-color" style="background:#f39c12;"></span> 绕行避让路段</div>
|
|
|
+ <div class="legend-item"><span class="legend-dot event"></span> 突发事件点</div>
|
|
|
+ <div class="legend-item"><span class="legend-dot person"></span> 值守/指挥人员站点</div>
|
|
|
+ <div class="legend-item"><span class="legend-dot route"></span> 推荐路线/备选站</div>
|
|
|
+ <div class="legend-item"><span class="legend-line"></span> 绕行通过路段</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -101,454 +132,191 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
|
|
-
|
|
|
-// ==================== 节点坐标数据 ====================
|
|
|
-const nodes = {
|
|
|
- "化工园区": { x: 180, y: 380, name: "化工园区" },
|
|
|
- "城南枢纽": { x: 250, y: 460, name: "城南枢纽" },
|
|
|
- "城西隧道": { x: 100, y: 280, name: "城西隧道" },
|
|
|
- "滨海大道": { x: 600, y: 200, name: "滨海大道" },
|
|
|
- "应急指挥中心": { x: 420, y: 300, name: "应急指挥中心" },
|
|
|
- "消防站A": { x: 320, y: 220, name: "消防站A" },
|
|
|
- "医疗点B": { x: 500, y: 400, name: "医疗点B" },
|
|
|
- "交警驻点": { x: 280, y: 360, name: "交警驻点" },
|
|
|
- "东侧入口": { x: 700, y: 320, name: "东侧入口" },
|
|
|
- "西侧绕行点": { x: 60, y: 400, name: "西侧绕行点" },
|
|
|
- "北环快速": { x: 350, y: 120, name: "北环快速" }
|
|
|
-}
|
|
|
-
|
|
|
-// ==================== 人员数据 ====================
|
|
|
-const personnel = ref([
|
|
|
- { id: 1, name: "张建国", role: "指挥人员", type: "commander", position: "应急指挥中心", nodeKey: "应急指挥中心" },
|
|
|
- { id: 2, name: "李卫东", role: "值守人员", type: "dispatcher", position: "消防站A", nodeKey: "消防站A" },
|
|
|
- { id: 3, name: "王敏", role: "值守人员", type: "dispatcher", position: "交警驻点", nodeKey: "交警驻点" },
|
|
|
- { id: 4, name: "陈方", role: "指挥人员", type: "commander", position: "医疗点B", nodeKey: "医疗点B" }
|
|
|
-])
|
|
|
-
|
|
|
-// ==================== 路网数据 ====================
|
|
|
-const edges = [
|
|
|
- { from: "化工园区", to: "交警驻点", dist: 3.2, speed: 40, time: 4.8 },
|
|
|
- { from: "化工园区", to: "城南枢纽", dist: 4.5, speed: 50, time: 5.4 },
|
|
|
- { from: "城南枢纽", to: "医疗点B", dist: 2.8, speed: 45, time: 3.7 },
|
|
|
- { from: "城南枢纽", to: "东侧入口", dist: 5.0, speed: 60, time: 5.0 },
|
|
|
- { from: "交警驻点", to: "应急指挥中心", dist: 2.5, speed: 40, time: 3.75 },
|
|
|
- { from: "交警驻点", to: "西侧绕行点", dist: 2.2, speed: 35, time: 3.77 },
|
|
|
- { from: "应急指挥中心", to: "消防站A", dist: 1.8, speed: 45, time: 2.4 },
|
|
|
- { from: "应急指挥中心", to: "北环快速", dist: 4.0, speed: 65, time: 3.69 },
|
|
|
- { from: "消防站A", to: "医疗点B", dist: 3.5, speed: 40, time: 5.25 },
|
|
|
- { from: "消防站A", to: "城西隧道", dist: 4.2, speed: 35, time: 7.2 },
|
|
|
- { from: "医疗点B", to: "滨海大道", dist: 5.8, speed: 55, time: 6.33 },
|
|
|
- { from: "北环快速", to: "滨海大道", dist: 6.2, speed: 70, time: 5.31 },
|
|
|
- { from: "西侧绕行点", to: "城西隧道", dist: 3.0, speed: 40, time: 4.5 },
|
|
|
- { from: "东侧入口", to: "滨海大道", dist: 2.9, speed: 60, time: 2.9 },
|
|
|
- { from: "城西隧道", to: "应急指挥中心", dist: 5.1, speed: 30, time: 10.2 },
|
|
|
- { from: "交警驻点", to: "东侧入口", dist: 6.3, speed: 55, time: 6.87 }
|
|
|
-]
|
|
|
-
|
|
|
-// ==================== 响应式数据 ====================
|
|
|
-const selectedEventLocation = ref("化工园区")
|
|
|
-const svgMap = ref(null)
|
|
|
-const activeRouteType = ref("shortest")
|
|
|
-const currentRoutes = ref({ shortest: null, fastest: null, personName: "" })
|
|
|
-
|
|
|
-// ==================== Dijkstra 算法 ====================
|
|
|
-const getAllNodeNames = () => Object.keys(nodes)
|
|
|
-
|
|
|
-const buildGraph = (weightType = 'dist') => {
|
|
|
- const graph = {}
|
|
|
- for (let node in nodes) graph[node] = []
|
|
|
- for (let edge of edges) {
|
|
|
- const weight = weightType === 'dist' ? edge.dist : edge.time
|
|
|
- graph[edge.from].push({ node: edge.to, weight })
|
|
|
- graph[edge.to].push({ node: edge.from, weight })
|
|
|
- }
|
|
|
- return graph
|
|
|
-}
|
|
|
+import { ref, reactive, computed, onMounted } from 'vue'
|
|
|
+import { Location, Position, User, Guide } from '@element-plus/icons-vue'
|
|
|
|
|
|
-const dijkstra = (graph, nodesList, start, end, weightKey) => {
|
|
|
- const distances = {}
|
|
|
- const previous = {}
|
|
|
- const unvisited = new Set()
|
|
|
- for (let node of nodesList) {
|
|
|
- distances[node] = Infinity
|
|
|
- previous[node] = null
|
|
|
- unvisited.add(node)
|
|
|
- }
|
|
|
- distances[start] = 0
|
|
|
-
|
|
|
- while (unvisited.size > 0) {
|
|
|
- let current = null
|
|
|
- for (let node of unvisited) {
|
|
|
- if (current === null || distances[node] < distances[current]) {
|
|
|
- current = node
|
|
|
- }
|
|
|
- }
|
|
|
- if (current === end || distances[current] === Infinity) break
|
|
|
- unvisited.delete(current)
|
|
|
-
|
|
|
- const neighbors = graph[current] || []
|
|
|
- for (let neighbor of neighbors) {
|
|
|
- const alt = distances[current] + neighbor.weight
|
|
|
- if (alt < distances[neighbor.node]) {
|
|
|
- distances[neighbor.node] = alt
|
|
|
- previous[neighbor.node] = current
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const path = []
|
|
|
- let u = end
|
|
|
- while (previous[u] !== null) {
|
|
|
- path.unshift(u)
|
|
|
- u = previous[u]
|
|
|
- }
|
|
|
- if (path.length > 0 && path[0] === end) path.unshift(start)
|
|
|
- else if (path.length === 0 && start === end) path.push(start)
|
|
|
-
|
|
|
- return { distance: distances[end], path }
|
|
|
+// 事件地点坐标
|
|
|
+const eventLocations = {
|
|
|
+ '化工园区': { lng: '118.50', lat: '31.20' },
|
|
|
+ '城南枢纽': { lng: '118.45', lat: '31.15' },
|
|
|
+ '城西隧道': { lng: '118.38', lat: '31.22' },
|
|
|
+ '滨海大道': { lng: '118.62', lat: '31.08' }
|
|
|
}
|
|
|
|
|
|
-// 计算最优路线
|
|
|
-const computeOptimalRoutes = (eventNode) => {
|
|
|
- const nodeNames = getAllNodeNames()
|
|
|
- const emergencyPersons = personnel.value.filter(p => p.role === "指挥人员" || p.role === "值守人员")
|
|
|
-
|
|
|
- let bestShortest = null
|
|
|
- let bestFastest = null
|
|
|
- let bestPerson = ""
|
|
|
-
|
|
|
- for (let person of emergencyPersons) {
|
|
|
- const startNode = person.nodeKey
|
|
|
- if (!startNode) continue
|
|
|
+const selectedEventLocation = ref('化工园区')
|
|
|
+const activeRouteType = ref('shortest')
|
|
|
|
|
|
- const graphDist = buildGraph('dist')
|
|
|
- const distResult = dijkstra(graphDist, nodeNames, startNode, eventNode, 'dist')
|
|
|
-
|
|
|
- const graphTime = buildGraph('time')
|
|
|
- const timeResult = dijkstra(graphTime, nodeNames, startNode, eventNode, 'time')
|
|
|
+// 人员数据
|
|
|
+const personnel = ref([
|
|
|
+ { id: 1, name: '张建国', role: '指挥人员', position: '应急指挥中心' },
|
|
|
+ { id: 2, name: '李卫东', role: '值守人员', position: '消防站A' },
|
|
|
+ { id: 3, name: '王敏', role: '值守人员', position: '交警驻点' },
|
|
|
+ { id: 4, name: '陈方', role: '指挥人员', position: '医疗点B' }
|
|
|
+])
|
|
|
|
|
|
- if (!bestShortest || (distResult.distance < bestShortest.distance)) {
|
|
|
- bestShortest = distResult
|
|
|
- bestPerson = person.name
|
|
|
+// 地图标签
|
|
|
+const mapLabels = {
|
|
|
+ '北环快速': { x: 42, y: 12 },
|
|
|
+ '城西隧道': { x: 10, y: 40 },
|
|
|
+ '滨海大道': { x: 75, y: 28 },
|
|
|
+ '化工园区': { x: 22, y: 62 },
|
|
|
+ '城南枢纽': { x: 30, y: 78 },
|
|
|
+ '东侧入口': { x: 88, y: 52 }
|
|
|
+}
|
|
|
+
|
|
|
+// 地图标记点
|
|
|
+const mapMarkers = computed(() => {
|
|
|
+ const markers = [
|
|
|
+ { id: 'e1', name: '化工园区', x: 22, y: 62, type: 'event' },
|
|
|
+ { id: 'e2', name: '城南枢纽', x: 30, y: 78, type: 'event' },
|
|
|
+ { id: 'e3', name: '城西隧道', x: 10, y: 40, type: 'event' },
|
|
|
+ { id: 'e4', name: '滨海大道', x: 75, y: 28, type: 'event' },
|
|
|
+ { id: 'p1', name: '张建国', x: 52, y: 48, type: 'person' },
|
|
|
+ { id: 'p2', name: '李卫东', x: 40, y: 34, type: 'person' },
|
|
|
+ { id: 'p3', name: '王敏', x: 35, y: 58, type: 'person' },
|
|
|
+ { id: 'p4', name: '陈方', x: 62, y: 68, type: 'person' },
|
|
|
+ { id: 'r1', name: '西侧绕行点', x: 6, y: 68, type: 'route' },
|
|
|
+ { id: 'r2', name: '东侧入口', x: 88, y: 52, type: 'route' },
|
|
|
+ { id: 'r3', name: '北环快速', x: 42, y: 12, type: 'route' }
|
|
|
+ ]
|
|
|
+ // 高亮当前事件点
|
|
|
+ return markers.map(m => {
|
|
|
+ if (m.type === 'event' && m.name === selectedEventLocation.value) {
|
|
|
+ return { ...m, active: true }
|
|
|
}
|
|
|
- if (!bestFastest || (timeResult.distance < bestFastest.distance)) {
|
|
|
- bestFastest = timeResult
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- return { shortest: bestShortest, fastest: bestFastest, personName: bestPerson }
|
|
|
-}
|
|
|
-
|
|
|
-// ==================== 显示路线数据 ====================
|
|
|
-const displayRoutes = computed(() => {
|
|
|
- const routes = []
|
|
|
- if (currentRoutes.value.shortest) {
|
|
|
- routes.push({
|
|
|
- type: "shortest",
|
|
|
- name: "最短路径 (距离优先)",
|
|
|
- icon: "📏",
|
|
|
- metricValue: `${currentRoutes.value.shortest.distance?.toFixed(1)} km`,
|
|
|
- path: currentRoutes.value.shortest.path,
|
|
|
- pathDisplay: currentRoutes.value.shortest.path?.join(' → ') || '',
|
|
|
- estimateTime: `预估耗时 ${((currentRoutes.value.shortest.distance / 40) * 60).toFixed(0)}分钟`,
|
|
|
- personName: currentRoutes.value.personName
|
|
|
- })
|
|
|
- }
|
|
|
- if (currentRoutes.value.fastest) {
|
|
|
- routes.push({
|
|
|
- type: "fastest",
|
|
|
- name: "最快路径 (时间优先)",
|
|
|
- icon: "⚡",
|
|
|
- metricValue: `${currentRoutes.value.fastest.distance?.toFixed(1)} min`,
|
|
|
- path: currentRoutes.value.fastest.path,
|
|
|
- pathDisplay: currentRoutes.value.fastest.path?.join(' → ') || '',
|
|
|
- estimateTime: `避开拥堵,节省约 ${Math.floor(Math.random() * 10 + 3)}% 时间`,
|
|
|
- personName: currentRoutes.value.personName
|
|
|
- })
|
|
|
- }
|
|
|
- return routes
|
|
|
+ return { ...m, active: false }
|
|
|
+ })
|
|
|
})
|
|
|
|
|
|
-// ==================== GIS 地图绘制 ====================
|
|
|
-const drawGIS = (eventNodeKey, activePath = null) => {
|
|
|
- if (!svgMap.value) return
|
|
|
-
|
|
|
- const svg = svgMap.value
|
|
|
- svg.innerHTML = ''
|
|
|
-
|
|
|
- // 绘制道路
|
|
|
- for (let edge of edges) {
|
|
|
- const fromNode = nodes[edge.from]
|
|
|
- const toNode = nodes[edge.to]
|
|
|
- if (fromNode && toNode) {
|
|
|
- const line = document.createElementNS("http://www.w3.org/2000/svg", "line")
|
|
|
- line.setAttribute("x1", fromNode.x)
|
|
|
- line.setAttribute("y1", fromNode.y)
|
|
|
- line.setAttribute("x2", toNode.x)
|
|
|
- line.setAttribute("y2", toNode.y)
|
|
|
- line.setAttribute("stroke", "#7f8c8d")
|
|
|
- line.setAttribute("stroke-width", "2")
|
|
|
- line.setAttribute("stroke-dasharray", "4 2")
|
|
|
- line.setAttribute("opacity", "0.5")
|
|
|
- svg.appendChild(line)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 绘制节点
|
|
|
- for (let key in nodes) {
|
|
|
- const node = nodes[key]
|
|
|
- const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle")
|
|
|
- circle.setAttribute("cx", node.x)
|
|
|
- circle.setAttribute("cy", node.y)
|
|
|
- circle.setAttribute("r", "6")
|
|
|
- circle.setAttribute("fill", "#95a5a6")
|
|
|
- circle.setAttribute("stroke", "white")
|
|
|
- circle.setAttribute("stroke-width", "1.5")
|
|
|
- svg.appendChild(circle)
|
|
|
-
|
|
|
- const text = document.createElementNS("http://www.w3.org/2000/svg", "text")
|
|
|
- text.setAttribute("x", node.x + 8)
|
|
|
- text.setAttribute("y", node.y - 5)
|
|
|
- text.setAttribute("fill", "#ecf0f1")
|
|
|
- text.setAttribute("font-size", "8")
|
|
|
- text.textContent = node.name
|
|
|
- svg.appendChild(text)
|
|
|
+// 地图路线(模拟)
|
|
|
+const mapLines = computed(() => {
|
|
|
+ const lines = []
|
|
|
+ // 基础道路
|
|
|
+ lines.push({ style: { left: '22%', top: '62%', width: '14%', transform: 'rotate(30deg)' }, type: 'road' })
|
|
|
+ lines.push({ style: { left: '22%', top: '62%', width: '10%', transform: 'rotate(-60deg)' }, type: 'road' })
|
|
|
+ lines.push({ style: { left: '40%', top: '34%', width: '14%', transform: 'rotate(40deg)' }, type: 'road' })
|
|
|
+ lines.push({ style: { left: '52%', top: '48%', width: '12%', transform: 'rotate(50deg)' }, type: 'road' })
|
|
|
+ lines.push({ style: { left: '35%', top: '58%', width: '18%', transform: 'rotate(-20deg)' }, type: 'road' })
|
|
|
+
|
|
|
+ // 根据选中路线类型高亮
|
|
|
+ if (activeRouteType.value === 'shortest') {
|
|
|
+ lines.push({ style: { left: '35%', top: '58%', width: '18%', transform: 'rotate(-20deg)' }, type: 'active-route' })
|
|
|
+ lines.push({ style: { left: '52%', top: '48%', width: '12%', transform: 'rotate(50deg)' }, type: 'active-route' })
|
|
|
+ } else {
|
|
|
+ lines.push({ style: { left: '22%', top: '62%', width: '10%', transform: 'rotate(-60deg)' }, type: 'active-route' })
|
|
|
+ lines.push({ style: { left: '10%', top: '40%', width: '28%', transform: 'rotate(5deg)' }, type: 'active-route' })
|
|
|
}
|
|
|
+ return lines
|
|
|
+})
|
|
|
|
|
|
- // 事件点标记
|
|
|
- const eventNodeObj = nodes[eventNodeKey]
|
|
|
- if (eventNodeObj) {
|
|
|
- const eventMarker = document.createElementNS("http://www.w3.org/2000/svg", "circle")
|
|
|
- eventMarker.setAttribute("cx", eventNodeObj.x)
|
|
|
- eventMarker.setAttribute("cy", eventNodeObj.y)
|
|
|
- eventMarker.setAttribute("r", "12")
|
|
|
- eventMarker.setAttribute("fill", "#e74c3c")
|
|
|
- eventMarker.setAttribute("stroke", "white")
|
|
|
- eventMarker.setAttribute("stroke-width", "2")
|
|
|
- svg.appendChild(eventMarker)
|
|
|
- }
|
|
|
-
|
|
|
- // 人员标记
|
|
|
- for (let p of personnel.value) {
|
|
|
- const posNode = nodes[p.nodeKey]
|
|
|
- if (posNode) {
|
|
|
- const personMark = document.createElementNS("http://www.w3.org/2000/svg", "circle")
|
|
|
- personMark.setAttribute("cx", posNode.x)
|
|
|
- personMark.setAttribute("cy", posNode.y)
|
|
|
- personMark.setAttribute("r", "8")
|
|
|
- personMark.setAttribute("fill", p.role === "指挥人员" ? "#f39c12" : "#3498db")
|
|
|
- personMark.setAttribute("stroke", "white")
|
|
|
- svg.appendChild(personMark)
|
|
|
-
|
|
|
- const label = document.createElementNS("http://www.w3.org/2000/svg", "text")
|
|
|
- label.setAttribute("x", posNode.x + 9)
|
|
|
- label.setAttribute("y", posNode.y - 3)
|
|
|
- label.setAttribute("fill", "#f1c40f")
|
|
|
- label.setAttribute("font-size", "7")
|
|
|
- label.textContent = p.name.charAt(0)
|
|
|
- svg.appendChild(label)
|
|
|
- }
|
|
|
+// 路线数据
|
|
|
+const routeData = reactive({
|
|
|
+ '化工园区': {
|
|
|
+ shortest: { distance: '3.2', pathDisplay: '交警驻点 → 化工园区', estimateTime: '5', viaArea: '城东主干道' },
|
|
|
+ fastest: { time: '4.8', pathDisplay: '消防站A → 应急指挥中心 → 化工园区', savePercent: '15' }
|
|
|
+ },
|
|
|
+ '城南枢纽': {
|
|
|
+ shortest: { distance: '2.8', pathDisplay: '医疗点B → 城南枢纽', estimateTime: '4', viaArea: '城南大道' },
|
|
|
+ fastest: { time: '3.7', pathDisplay: '应急指挥中心 → 交警驻点 → 城南枢纽', savePercent: '12' }
|
|
|
+ },
|
|
|
+ '城西隧道': {
|
|
|
+ shortest: { distance: '4.2', pathDisplay: '消防站A → 城西隧道', estimateTime: '7', viaArea: '城西快速路' },
|
|
|
+ fastest: { time: '5.5', pathDisplay: '应急指挥中心 → 北环快速 → 城西隧道', savePercent: '20' }
|
|
|
+ },
|
|
|
+ '滨海大道': {
|
|
|
+ shortest: { distance: '5.8', pathDisplay: '医疗点B → 滨海大道', estimateTime: '6', viaArea: '滨海路' },
|
|
|
+ fastest: { time: '4.2', pathDisplay: '北环快速 → 东侧入口 → 滨海大道', savePercent: '18' }
|
|
|
}
|
|
|
+})
|
|
|
|
|
|
- // 绘制路径
|
|
|
- if (activePath && activePath.length > 1) {
|
|
|
- for (let i = 0; i < activePath.length - 1; i++) {
|
|
|
- const fromNode = nodes[activePath[i]]
|
|
|
- const toNode = nodes[activePath[i + 1]]
|
|
|
- if (fromNode && toNode) {
|
|
|
- const line = document.createElementNS("http://www.w3.org/2000/svg", "line")
|
|
|
- line.setAttribute("x1", fromNode.x)
|
|
|
- line.setAttribute("y1", fromNode.y)
|
|
|
- line.setAttribute("x2", toNode.x)
|
|
|
- line.setAttribute("y2", toNode.y)
|
|
|
- line.setAttribute("stroke", "#e07c3c")
|
|
|
- line.setAttribute("stroke-width", "5")
|
|
|
- line.setAttribute("stroke-linecap", "round")
|
|
|
- line.setAttribute("stroke-dasharray", "8 4")
|
|
|
- svg.appendChild(line)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
+const displayRoutes = computed(() => {
|
|
|
+ const data = routeData[selectedEventLocation.value]
|
|
|
+ return data || { shortest: null, fastest: null }
|
|
|
+})
|
|
|
|
|
|
-// ==================== 选择路线 ====================
|
|
|
-const selectRoute = (route) => {
|
|
|
- activeRouteType.value = route.type
|
|
|
- const path = route.path
|
|
|
- if (path && path.length > 1) {
|
|
|
- drawGIS(selectedEventLocation.value, path)
|
|
|
- }
|
|
|
+const selectRoute = (type) => {
|
|
|
+ activeRouteType.value = type
|
|
|
}
|
|
|
|
|
|
-// ==================== 刷新所有数据 ====================
|
|
|
const refreshAll = () => {
|
|
|
- const routes = computeOptimalRoutes(selectedEventLocation.value)
|
|
|
- currentRoutes.value = {
|
|
|
- shortest: routes.shortest,
|
|
|
- fastest: routes.fastest,
|
|
|
- personName: routes.personName
|
|
|
- }
|
|
|
-
|
|
|
- const defaultPath = routes.shortest?.path || null
|
|
|
- drawGIS(selectedEventLocation.value, defaultPath)
|
|
|
- activeRouteType.value = "shortest"
|
|
|
+ activeRouteType.value = 'shortest'
|
|
|
}
|
|
|
|
|
|
-// ==================== 社会公众绕行方案 ====================
|
|
|
-const showPublicRoute = () => {
|
|
|
- alert("🚸 社会公众绕行方案:\n\n根据突发事件位置,推荐绕行路线:\n• 避开化工园区及城南枢纽路段\n• 建议走东侧入口 → 北环快速 → 滨海大道方向\n• 可通过交通广播及导航APP获取实时绕行指引\n• 请关注官方发布的最新路况信息")
|
|
|
-}
|
|
|
-
|
|
|
-// ==================== 生命周期 ====================
|
|
|
onMounted(() => {
|
|
|
refreshAll()
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
-* {
|
|
|
- margin: 0;
|
|
|
- padding: 0;
|
|
|
- box-sizing: border-box;
|
|
|
-}
|
|
|
-
|
|
|
-.prototype-container {
|
|
|
- max-width: 1600px;
|
|
|
- margin: 0 auto;
|
|
|
- background: #ffffff;
|
|
|
- border-radius: 28px;
|
|
|
- box-shadow: 0 20px 35px -12px rgba(0, 0, 0, 0.15);
|
|
|
- overflow: hidden;
|
|
|
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
|
-}
|
|
|
-
|
|
|
-/* 头部 */
|
|
|
-.header {
|
|
|
- background: #0a2b3c;
|
|
|
- padding: 16px 28px;
|
|
|
- display: flex;
|
|
|
- justify-content: space-between;
|
|
|
- align-items: center;
|
|
|
- flex-wrap: wrap;
|
|
|
- gap: 12px;
|
|
|
- border-bottom: 3px solid #e07c3c;
|
|
|
-}
|
|
|
-
|
|
|
-.logo h2 {
|
|
|
- color: white;
|
|
|
- font-weight: 600;
|
|
|
- font-size: 1.4rem;
|
|
|
-}
|
|
|
-
|
|
|
-.logo p {
|
|
|
- color: #bdd8e8;
|
|
|
- font-size: 0.7rem;
|
|
|
-}
|
|
|
-
|
|
|
-.badge-gis {
|
|
|
- background: #e07c3c;
|
|
|
- padding: 6px 18px;
|
|
|
- border-radius: 30px;
|
|
|
- color: white;
|
|
|
- font-weight: 500;
|
|
|
- font-size: 0.8rem;
|
|
|
+.app-container {
|
|
|
+ padding: 16px;
|
|
|
+ background: #f0f2f5;
|
|
|
+ min-height: calc(100vh - 84px);
|
|
|
}
|
|
|
|
|
|
-/* 主体布局 */
|
|
|
-.main-layout {
|
|
|
+.route-layout {
|
|
|
display: flex;
|
|
|
- flex-wrap: wrap;
|
|
|
+ gap: 16px;
|
|
|
+ height: calc(100vh - 120px);
|
|
|
}
|
|
|
|
|
|
-.info-panel {
|
|
|
- width: 360px;
|
|
|
- background: #f9fbfd;
|
|
|
- border-right: 1px solid #e2edf2;
|
|
|
- padding: 20px 18px;
|
|
|
+.left-panel {
|
|
|
+ width: 30%;
|
|
|
+ min-width: 320px;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
- gap: 20px;
|
|
|
+ gap: 12px;
|
|
|
+ overflow-y: auto;
|
|
|
}
|
|
|
|
|
|
-.map-panel {
|
|
|
+.right-panel {
|
|
|
flex: 1;
|
|
|
- background: #1a2f3c;
|
|
|
- padding: 16px;
|
|
|
- position: relative;
|
|
|
- min-height: 600px;
|
|
|
+ min-width: 0;
|
|
|
}
|
|
|
|
|
|
-/* 卡片样式 */
|
|
|
-.card {
|
|
|
- background: white;
|
|
|
- border-radius: 20px;
|
|
|
- border: 1px solid #e6edf3;
|
|
|
- overflow: hidden;
|
|
|
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
|
|
+.panel-card {
|
|
|
+ border-radius: 8px;
|
|
|
}
|
|
|
|
|
|
-.card-header {
|
|
|
- background: #f9fafc;
|
|
|
- padding: 14px 18px;
|
|
|
- border-bottom: 1px solid #eef2f6;
|
|
|
- font-weight: 700;
|
|
|
- font-size: 0.9rem;
|
|
|
- color: #1d5a78;
|
|
|
+.card-title {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ font-weight: 600;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #303133;
|
|
|
}
|
|
|
|
|
|
-.card-body {
|
|
|
- padding: 16px 18px;
|
|
|
+.card-title .el-icon {
|
|
|
+ color: #409EFF;
|
|
|
+ font-size: 16px;
|
|
|
}
|
|
|
|
|
|
-.form-group {
|
|
|
- margin-bottom: 16px;
|
|
|
+/* 事件定位 */
|
|
|
+.event-location {
|
|
|
+ margin-bottom: 12px;
|
|
|
}
|
|
|
|
|
|
-.form-label {
|
|
|
- font-size: 0.75rem;
|
|
|
- font-weight: 600;
|
|
|
- margin-bottom: 6px;
|
|
|
- display: block;
|
|
|
- color: #2c5a72;
|
|
|
+.loc-row {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ padding: 6px 0;
|
|
|
+ font-size: 13px;
|
|
|
}
|
|
|
|
|
|
-.form-select {
|
|
|
- width: 100%;
|
|
|
- padding: 8px 12px;
|
|
|
- border-radius: 12px;
|
|
|
- border: 1px solid #cbdde6;
|
|
|
- font-size: 0.8rem;
|
|
|
- background: white;
|
|
|
+.loc-label {
|
|
|
+ color: #909399;
|
|
|
+ font-size: 12px;
|
|
|
}
|
|
|
|
|
|
-.btn-primary {
|
|
|
- background: #e07c3c;
|
|
|
- border: none;
|
|
|
- color: white;
|
|
|
- padding: 10px 16px;
|
|
|
- border-radius: 30px;
|
|
|
+.loc-value {
|
|
|
+ font-family: monospace;
|
|
|
+ color: #303133;
|
|
|
font-weight: 600;
|
|
|
- font-size: 0.8rem;
|
|
|
- cursor: pointer;
|
|
|
- width: 100%;
|
|
|
- transition: 0.2s;
|
|
|
-}
|
|
|
-
|
|
|
-.btn-primary:hover {
|
|
|
- background: #c56329;
|
|
|
-}
|
|
|
-
|
|
|
-.btn-outline {
|
|
|
- background: white;
|
|
|
- border: 1px solid #e07c3c;
|
|
|
- color: #e07c3c;
|
|
|
- padding: 6px 14px;
|
|
|
- border-radius: 30px;
|
|
|
- font-size: 0.7rem;
|
|
|
- cursor: pointer;
|
|
|
}
|
|
|
|
|
|
+/* 人员列表 */
|
|
|
.person-list {
|
|
|
max-height: 180px;
|
|
|
overflow-y: auto;
|
|
|
@@ -557,98 +325,239 @@ onMounted(() => {
|
|
|
.person-item {
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
padding: 8px 0;
|
|
|
- border-bottom: 1px solid #edf2f6;
|
|
|
- font-size: 0.75rem;
|
|
|
+ border-bottom: 1px solid #f0f0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.person-item:last-child {
|
|
|
+ border-bottom: none;
|
|
|
+}
|
|
|
+
|
|
|
+.person-info {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.person-name {
|
|
|
+ font-weight: 600;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #303133;
|
|
|
}
|
|
|
|
|
|
-.role-tag {
|
|
|
- background: #eef2fa;
|
|
|
- padding: 2px 8px;
|
|
|
- border-radius: 20px;
|
|
|
- font-size: 0.65rem;
|
|
|
+.person-tip {
|
|
|
+ margin-top: 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ text-align: center;
|
|
|
}
|
|
|
|
|
|
-.route-result {
|
|
|
- margin-top: 12px;
|
|
|
+/* 路线推荐 */
|
|
|
+.route-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
}
|
|
|
|
|
|
.route-item {
|
|
|
- background: #fef7f0;
|
|
|
- border-radius: 14px;
|
|
|
padding: 12px;
|
|
|
- margin-bottom: 10px;
|
|
|
- border-left: 3px solid #e07c3c;
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ border-radius: 6px;
|
|
|
cursor: pointer;
|
|
|
- transition: 0.1s;
|
|
|
+ transition: all 0.2s;
|
|
|
+ background: #fafafa;
|
|
|
+}
|
|
|
+
|
|
|
+.route-item:hover {
|
|
|
+ border-color: #409EFF;
|
|
|
}
|
|
|
|
|
|
.route-item.active {
|
|
|
- background: #f0e8df;
|
|
|
- border-left-color: #2e9c8c;
|
|
|
+ border-color: #409EFF;
|
|
|
+ border-left: 3px solid #409EFF;
|
|
|
+ background: #ecf5ff;
|
|
|
}
|
|
|
|
|
|
-.route-name {
|
|
|
- font-weight: 700;
|
|
|
- font-size: 0.85rem;
|
|
|
+.route-head {
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 6px;
|
|
|
}
|
|
|
|
|
|
-.route-detail {
|
|
|
- font-size: 0.7rem;
|
|
|
- color: #6f8ea8;
|
|
|
- margin-top: 6px;
|
|
|
+.route-name {
|
|
|
+ font-weight: 600;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #303133;
|
|
|
}
|
|
|
|
|
|
-.route-meta {
|
|
|
- display: flex;
|
|
|
- gap: 12px;
|
|
|
- margin-top: 8px;
|
|
|
- font-size: 0.7rem;
|
|
|
+.route-path {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+ margin-bottom: 4px;
|
|
|
}
|
|
|
|
|
|
-.badge-dist {
|
|
|
- background: #e9ecef;
|
|
|
- padding: 2px 8px;
|
|
|
- border-radius: 20px;
|
|
|
+.route-meta {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
}
|
|
|
|
|
|
-/* GIS 地图容器 */
|
|
|
-.gis-container {
|
|
|
- background: #1e3a4d;
|
|
|
- border-radius: 20px;
|
|
|
- height: 100%;
|
|
|
- min-height: 580px;
|
|
|
+/* 地图区域 */
|
|
|
+.map-container {
|
|
|
position: relative;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ background: #1a2a3a;
|
|
|
+ border-radius: 8px;
|
|
|
overflow: hidden;
|
|
|
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
|
+ min-height: 500px;
|
|
|
}
|
|
|
|
|
|
-.map-svg {
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
- background: linear-gradient(135deg, #234a5e 0%, #1a3a4a 100%);
|
|
|
+/* 地名标签 */
|
|
|
+.map-label {
|
|
|
+ position: absolute;
|
|
|
+ color: rgba(255, 255, 255, 0.5);
|
|
|
+ font-size: 11px;
|
|
|
+ pointer-events: none;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ white-space: nowrap;
|
|
|
+ letter-spacing: 1px;
|
|
|
}
|
|
|
|
|
|
+/* 标记点 */
|
|
|
+.map-marker {
|
|
|
+ position: absolute;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ z-index: 10;
|
|
|
+}
|
|
|
+
|
|
|
+.marker-dot {
|
|
|
+ width: 14px;
|
|
|
+ height: 14px;
|
|
|
+ border-radius: 50%;
|
|
|
+ border: 2px solid #fff;
|
|
|
+ position: relative;
|
|
|
+ z-index: 2;
|
|
|
+}
|
|
|
+
|
|
|
+.map-marker.event .marker-dot {
|
|
|
+ background: #e74c3c;
|
|
|
+ width: 18px;
|
|
|
+ height: 18px;
|
|
|
+ box-shadow: 0 0 12px rgba(231, 76, 60, 0.6);
|
|
|
+}
|
|
|
+
|
|
|
+.map-marker.person .marker-dot {
|
|
|
+ background: #3498db;
|
|
|
+ box-shadow: 0 0 8px rgba(52, 152, 219, 0.5);
|
|
|
+}
|
|
|
+
|
|
|
+.map-marker.route .marker-dot {
|
|
|
+ background: #e67e22;
|
|
|
+ box-shadow: 0 0 8px rgba(230, 126, 34, 0.5);
|
|
|
+}
|
|
|
+
|
|
|
+.marker-pulse {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ width: 36px;
|
|
|
+ height: 36px;
|
|
|
+ margin-left: -18px;
|
|
|
+ margin-top: -18px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(231, 76, 60, 0.2);
|
|
|
+ animation: pulse 2s ease-out infinite;
|
|
|
+ z-index: 1;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes pulse {
|
|
|
+ 0% { transform: scale(0.5); opacity: 1; }
|
|
|
+ 100% { transform: scale(1.5); opacity: 0; }
|
|
|
+}
|
|
|
+
|
|
|
+.marker-label {
|
|
|
+ position: absolute;
|
|
|
+ left: 50%;
|
|
|
+ top: 100%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+ margin-top: 4px;
|
|
|
+ font-size: 10px;
|
|
|
+ color: rgba(255, 255, 255, 0.85);
|
|
|
+ white-space: nowrap;
|
|
|
+ background: rgba(0, 0, 0, 0.5);
|
|
|
+ padding: 1px 6px;
|
|
|
+ border-radius: 3px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 路线连接 */
|
|
|
+.map-line {
|
|
|
+ position: absolute;
|
|
|
+ height: 2px;
|
|
|
+ transform-origin: left center;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+
|
|
|
+.map-line.road {
|
|
|
+ background: rgba(255, 255, 255, 0.15);
|
|
|
+ border: none;
|
|
|
+}
|
|
|
+
|
|
|
+.map-line.active-route {
|
|
|
+ height: 3px;
|
|
|
+ background: #e67e22;
|
|
|
+ box-shadow: 0 0 6px rgba(230, 126, 34, 0.6);
|
|
|
+ z-index: 5;
|
|
|
+}
|
|
|
+
|
|
|
+/* 图例 */
|
|
|
.map-legend {
|
|
|
position: absolute;
|
|
|
bottom: 16px;
|
|
|
right: 16px;
|
|
|
- background: rgba(0, 0, 0, 0.7);
|
|
|
- padding: 8px 14px;
|
|
|
- border-radius: 20px;
|
|
|
- color: white;
|
|
|
- font-size: 0.7rem;
|
|
|
+ background: rgba(0, 0, 0, 0.75);
|
|
|
+ padding: 10px 16px;
|
|
|
+ border-radius: 8px;
|
|
|
+ color: #fff;
|
|
|
+ font-size: 12px;
|
|
|
+ z-index: 20;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
backdrop-filter: blur(4px);
|
|
|
- z-index: 10;
|
|
|
}
|
|
|
|
|
|
-.legend-color {
|
|
|
- display: inline-block;
|
|
|
- width: 12px;
|
|
|
- height: 12px;
|
|
|
+.legend-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.legend-dot {
|
|
|
+ width: 10px;
|
|
|
+ height: 10px;
|
|
|
+ border-radius: 50%;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.legend-dot.event {
|
|
|
+ background: #e74c3c;
|
|
|
+}
|
|
|
+
|
|
|
+.legend-dot.person {
|
|
|
+ background: #3498db;
|
|
|
+}
|
|
|
+
|
|
|
+.legend-dot.route {
|
|
|
+ background: #e67e22;
|
|
|
+}
|
|
|
+
|
|
|
+.legend-line {
|
|
|
+ width: 18px;
|
|
|
+ height: 3px;
|
|
|
+ background: #e67e22;
|
|
|
border-radius: 2px;
|
|
|
- margin-right: 4px;
|
|
|
+ flex-shrink: 0;
|
|
|
}
|
|
|
-</style>
|
|
|
+</style>
|