TongzhouTrafficMap.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. <template>
  2. <div class="map-wrapper">
  3. <div ref="mapContainer" class="map-container"></div>
  4. <div class="map-legend" :style="privateStyle.legend" v-if="showLegend">
  5. <div class="legend-title">图例</div>
  6. <div class="legend-list">
  7. <div class="legend-item all-select" @click="toggleAll">
  8. <div class="legend-dot"
  9. :style="{ backgroundColor: isAllSelected ? '#fff' : 'transparent', border: '1px solid #fff' }"></div>
  10. <div class="legend-label" style="font-weight: bold;">全选</div>
  11. </div>
  12. <div v-for="item in legendConfig" class="legend-item" @click="toggleRouteVisible(item.name)" :key="item.name"
  13. :class="{ 'is-inactive': !activeLegends.includes(item.name) }"
  14. v-if="showSpecialRoutes || !['干线协调', '勤务路线'].includes(item.name)">
  15. <div class="legend-dot"
  16. :style="{ backgroundColor: ['离线', '降级', '故障'].includes(item.name) ? 'transparent' : item.color }"
  17. :class="{ 'special-route': ['干线协调', '勤务路线'].includes(item.name), 'is-status-wrapper': ['离线', '降级', '故障'].includes(item.name) }">
  18. <span v-if="!['离线', '降级', '故障'].includes(item.name)">
  19. {{ item.name.charAt(0) }}
  20. </span>
  21. <img v-else
  22. :src="require(`@/assets/images/icon_${item.name === '离线' ? 'lixian' : item.name === '降级' ? 'jiangji' : 'guzhang'}.png`)"
  23. class="status-icon" />
  24. </div>
  25. <div class="legend-label">{{ item.name }}</div>
  26. </div>
  27. </div>
  28. </div>
  29. </div>
  30. </template>
  31. <script>
  32. import AMapLoader from '@amap/amap-jsapi-loader';
  33. export default {
  34. name: "TrafficMap",
  35. props: {
  36. amapKey: { type: String, default: '您的Key' },
  37. securityJsCode: { type: String, default: '您的安全密钥' },
  38. showSpecialRoutes: { type: Boolean, default: true },
  39. showLegend: { type: Boolean, default: true }
  40. },
  41. data() {
  42. return {
  43. AMap: null,
  44. map: null,
  45. infoWindow: null,
  46. routeGroups: {},
  47. polylines: [],
  48. privateStyle: {
  49. legend: {}
  50. },
  51. activeLegends: ["中心计划", "干线协调", "勤务路线", "定周期控制", "感应控制", "自适应控制", "手动控制", "特殊控制", "离线", "降级", "故障"],
  52. legendConfig: [
  53. // 横向主干道 (由北向南)
  54. { name: "中心计划", start: [116.6350, 39.9105], end: [116.6910, 39.9115], color: "#004CDE" }, // 新华大街全线
  55. { name: "干线协调", start: [116.6355, 39.9025], end: [116.6915, 39.9035], color: "#13C373" }, // 玉带河大街全线
  56. { name: "勤务路线", start: [116.6360, 39.8945], end: [116.6920, 39.8955], color: "#BC301D" }, // 运河西大街全线
  57. { name: "定周期控制", start: [116.6521, 39.9200], end: [116.6531, 39.8800], color: "#3296FA" }, // 车站路
  58. { name: "感应控制", start: [116.6611, 39.9205], end: [116.6615, 39.8805], color: "#FF864C" }, // 新华南路
  59. { name: "自适应控制", start: [116.6711, 39.9210], end: [116.6720, 39.8810], color: "#9F6EFE" }, // 东关大道
  60. { name: "手动控制", start: [116.6300, 39.9150], end: [116.6310, 39.8850], color: "#EB9F36" }, // 北苑南路
  61. { name: "特殊控制", start: [116.6820, 39.9215], end: [116.6825, 39.8815], color: "#A26218" }, // 临河里路
  62. { name: "离线", start: [116.6415, 39.9235], end: [116.6850, 39.9240], color: "#7A7A7A" }, // 北关大街-潞苑
  63. { name: "降级", start: [116.6365, 39.8850], end: [116.6800, 39.8860], color: "#D9C13B" }, // 万盛南街段
  64. { name: "故障", start: [116.6950, 39.9150], end: [116.6955, 39.885], color: "#FF3938" } // 潞通大街
  65. ]
  66. };
  67. },
  68. mounted() {
  69. // 根据props调整activeLegends数组
  70. if (!this.showSpecialRoutes) {
  71. const trunkCoordinationIndex = this.activeLegends.indexOf('干线协调');
  72. if (trunkCoordinationIndex > -1) {
  73. this.activeLegends.splice(trunkCoordinationIndex, 1);
  74. }
  75. const serviceRouteIndex = this.activeLegends.indexOf('勤务路线');
  76. if (serviceRouteIndex > -1) {
  77. this.activeLegends.splice(serviceRouteIndex, 1);
  78. }
  79. }
  80. this.initAMap();
  81. // 自定义首页地图搜索和图例位置样式
  82. if (this.$route.path === '/home') {
  83. this.privateStyle.legend = { right: "25%" };
  84. }
  85. },
  86. beforeDestroy() {
  87. if (this.infoWindow) this.infoWindow.close();
  88. // 1. 清理普通的 polylines 数组
  89. this.polylines.forEach(p => {
  90. if (p && typeof p.setMap === 'function') p.setMap(null);
  91. });
  92. // 2. 核心修改:清理 routeGroups
  93. Object.values(this.routeGroups).forEach(g => {
  94. if (!g) return;
  95. if (Array.isArray(g)) {
  96. // 如果是数组(当前的逻辑),遍历每个成员销毁
  97. g.forEach(overlay => {
  98. if (overlay && typeof overlay.setMap === 'function') {
  99. overlay.setMap(null);
  100. }
  101. });
  102. } else if (typeof g.setMap === 'function') {
  103. // 如果是旧版的 OverlayGroup 或单个覆盖物
  104. g.setMap(null);
  105. }
  106. });
  107. if (this.map) {
  108. this.map.destroy();
  109. }
  110. },
  111. computed: {
  112. // 判断是否所有图例都在激活列表中
  113. isAllSelected() {
  114. return this.activeLegends.length === this.legendConfig.length;
  115. }
  116. },
  117. methods: {
  118. // 修改后的 initAMap
  119. async initAMap() {
  120. window._AMapSecurityConfig = { securityJsCode: this.securityJsCode };
  121. try {
  122. this.AMap = await AMapLoader.load({
  123. key: this.amapKey,
  124. version: "2.0",
  125. plugins: ['AMap.Driving', 'AMap.GeometryUtil']
  126. });
  127. this.map = new this.AMap.Map(this.$refs.mapContainer, {
  128. zoom: 13.5,
  129. mapStyle: "amap://styles/darkblue",
  130. center: [116.663, 39.905],
  131. });
  132. this.map.on('complete', () => this.drawStaticRoutes());
  133. } catch (err) { console.error('地图初始化失败', err); }
  134. },
  135. drawStaticRoutes() {
  136. const AMap = this.AMap;
  137. this.legendConfig.forEach((config, index) => {
  138. // 当showSpecialRoutes为false时,跳过干线协调和勤务路线
  139. if (!this.showSpecialRoutes && ['干线协调', '勤务路线'].includes(config.name)) {
  140. return;
  141. }
  142. setTimeout(() => {
  143. const driving = new AMap.Driving({
  144. map: null,
  145. hideMarkers: true,
  146. autoFitView: false
  147. });
  148. driving.search(config.start, config.end, (status, result) => {
  149. let markers = [];
  150. let path = [];
  151. if (status === 'complete' && result.routes[0]) {
  152. const route = result.routes[0];
  153. route.steps.forEach(step => { path = path.concat(step.path); });
  154. } else {
  155. path = [config.start, config.end];
  156. }
  157. let polyline = null;
  158. const needRouteLine = ["干线协调", "勤务路线"].includes(config.name);
  159. if (needRouteLine) {
  160. polyline = new AMap.Polyline({
  161. path: path,
  162. strokeColor: config.color,
  163. strokeWeight: 8,
  164. strokeOpacity: 0.8,
  165. showDir: false,
  166. lineJoin: 'round',
  167. zIndex: 15,
  168. map: null
  169. });
  170. }
  171. const points = [];
  172. const step = 10; // 步长越大,点越稀疏
  173. for (let i = 0; i < path.length; i += step) {
  174. points.push(path[i]);
  175. }
  176. // 确保终点也被加上
  177. if ((path.length - 1) % step !== 0) {
  178. points.push(path[path.length - 1]);
  179. }
  180. points.forEach((pos, idx) => {
  181. // 临时逻辑,有真实接口后可以删除
  182. const posMap = {
  183. 8: 'pos1',
  184. 9: 'pos2',
  185. 10: 'pos3'
  186. };
  187. if (idx === 0 && posMap[index]) {
  188. localStorage.setItem(posMap[index], pos);
  189. }
  190. // 临时逻辑,有真实接口后可以删除
  191. markers.push(this.createTrafficLightMarker(pos, config));
  192. });
  193. // 3. 【关键修改】:overlays 数组只存放 markers,不再存放 polyline
  194. const overlays = [...markers, polyline].filter(Boolean);
  195. this.routeGroups[config.name] = overlays;
  196. if (this.activeLegends.includes(config.name)) {
  197. this.map.add(overlays);
  198. }
  199. });
  200. }, index * 250);
  201. });
  202. },
  203. createTrafficLightMarker(position, config) {
  204. // 根据业务需求:离线、降级、故障需要闪烁,其他保持静止
  205. const needsFlash = ["离线", "降级", "故障"].includes(config.name);
  206. const displayStatus = needsFlash ? config.name : "正常运行";
  207. const lng = Number(position[0] || position.lng);
  208. const lat = Number(position[1] || position.lat);
  209. const marker = new this.AMap.Marker({
  210. position: [lng, lat],
  211. zIndex: 100,
  212. content: `
  213. <div class="pure-light-node ${needsFlash ? 'breathe' : ''}"
  214. style="background: ${config.color}; box-shadow: 0 0 15px ${config.color}; font-size: 12px; display: flex; justify-content: center; align-items: center; color: #fff; padding: 8px;">
  215. <span>${config.name.charAt(0)}</span>
  216. </div>
  217. `,
  218. offset: new this.AMap.Pixel(-10, -10),
  219. extData: {
  220. ...config,
  221. position: [lng, lat],
  222. statusColor: config.color, // 统一弹窗小圆点颜色
  223. statusLabel: displayStatus, // 统一弹窗状态文字
  224. road: '北京路与南京路',
  225. time: '2026.1.23.12:00'
  226. }
  227. });
  228. marker.on('click', (e) => {
  229. this.openLightInfo(e.target.getExtData(), e.lnglat);
  230. // 抛出地图路口点击事件
  231. this.$emit('map-crossing-click', e.target.getExtData(), e.lnglat);
  232. });
  233. return marker;
  234. },
  235. openLightInfo(data, position) {
  236. const content = `
  237. <div class="custom-info-card">
  238. <div class="close-btn" onclick="window.closeMapInfoWindow()">✕</div>
  239. <div class="card-header">
  240. <div class="status-dot" style="background: ${data.statusColor}">
  241. <span>${data.name.charAt(0)}</span>
  242. </div>
  243. <span class="status-text">${data.statusLabel}</span>
  244. </div>
  245. <div class="card-body">
  246. <div class="info-line">
  247. <span class="label">路口:</span>
  248. <span class="value">${data.road}</span>
  249. </div>
  250. <div class="info-line">
  251. <span class="label">发生时间:</span>
  252. <span class="value digital">${data.time}</span>
  253. </div>
  254. </div>
  255. </div>
  256. `;
  257. // 定义全局关闭方法(因为 isCustom:true 下 Vue 事件会失效)
  258. window.closeMapInfoWindow = () => {
  259. if (this.infoWindow) this.infoWindow.close();
  260. };
  261. if (!this.infoWindow) {
  262. this.infoWindow = new this.AMap.InfoWindow({
  263. isCustom: true,
  264. offset: new this.AMap.Pixel(0, -20)
  265. });
  266. }
  267. this.infoWindow.setContent(content);
  268. this.infoWindow.open(this.map, position);
  269. },
  270. // 全选/全不选逻辑修正版
  271. toggleAll() {
  272. const targetState = !this.isAllSelected; // 获取点击后的目标状态(true为全选,false为全不选)
  273. if (targetState) {
  274. // --- 情况 1:执行“全选” ---
  275. // 1. 更新激活列表
  276. this.activeLegends = this.legendConfig.map(item => item.name);
  277. // 2. 遍历所有已经生成的路线数组,将其全部添加到地图上
  278. Object.keys(this.routeGroups).forEach(name => {
  279. const overlays = this.routeGroups[name]; // 此时 routeGroups 存储的是数组
  280. if (overlays && overlays.length > 0) {
  281. this.map.add(overlays);
  282. }
  283. });
  284. } else {
  285. // --- 情况 2:执行“全不选” ---
  286. // 1. 清空激活列表
  287. this.activeLegends = [];
  288. // 2. 遍历所有路线数组,从地图上移除
  289. Object.keys(this.routeGroups).forEach(name => {
  290. const overlays = this.routeGroups[name];
  291. if (overlays && overlays.length > 0) {
  292. this.map.remove(overlays);
  293. }
  294. });
  295. // 3. 关闭当前可能打开的弹窗
  296. if (this.infoWindow) this.infoWindow.close();
  297. }
  298. },
  299. toggleRouteVisible(name) {
  300. const overlays = this.routeGroups[name] || []; // 获取的是数组
  301. const index = this.activeLegends.indexOf(name);
  302. if (index > -1) {
  303. this.activeLegends.splice(index, 1);
  304. this.map.remove(overlays); // 改用 remove
  305. if (this.infoWindow) this.infoWindow.close();
  306. } else {
  307. this.activeLegends.push(name);
  308. this.map.add(overlays); // 改用 add
  309. }
  310. },
  311. // 其他组件点击定位到地图指定的点
  312. focusByLocation(targetPos) {
  313. if (!targetPos || targetPos.length !== 2) return;
  314. let foundMarker = null;
  315. // 1. 遍历所有路线组
  316. Object.values(this.routeGroups).forEach(group => {
  317. // 2. 在组内寻找 Marker
  318. const marker = group.find(item => {
  319. if (!(item instanceof this.AMap.Marker)) return false;
  320. const pos = item.getExtData().position;
  321. // 3. 坐标比对(考虑到浮点数精度,建议使用 AMap 自带的几何工具或简单比对)
  322. return pos[0] === targetPos[0] && pos[1] === targetPos[1];
  323. });
  324. if (marker) foundMarker = marker;
  325. });
  326. if (foundMarker) {
  327. // 4. 定位并打开弹窗
  328. const finalPos = foundMarker.getPosition();
  329. this.map.setZoomAndCenter(17, finalPos, false, 500); // 17级视角,平滑移动
  330. setTimeout(() => {
  331. this.openLightInfo(foundMarker.getExtData(), finalPos);
  332. }, 600);
  333. } else {
  334. console.warn("未在地图上找到该坐标对应的点位:", targetPos);
  335. }
  336. }
  337. }
  338. };
  339. </script>
  340. <style scoped>
  341. .map-wrapper {
  342. width: 100%;
  343. height: 100vh;
  344. position: relative;
  345. background: #010813;
  346. }
  347. .map-container {
  348. width: 100%;
  349. height: 100%;
  350. }
  351. ::v-deep .pure-light-node {
  352. width: 16px;
  353. height: 16px;
  354. border-radius: 50%;
  355. border: 2px solid rgba(255, 255, 255, 0.8);
  356. cursor: pointer;
  357. transition: all 0.3s;
  358. }
  359. ::v-deep .pure-light-node.breathe {
  360. animation: light-breathe 2s infinite ease-in-out;
  361. }
  362. ::v-deep .pure-light-node span {
  363. display: flex;
  364. transform: scale(0.75);
  365. align-items: center;
  366. }
  367. ::v-deep .pure-light-node:hover {
  368. transform: scale(1.4);
  369. filter: brightness(1.2);
  370. }
  371. @keyframes light-breathe {
  372. 0%,
  373. 100% {
  374. opacity: 0.7;
  375. transform: scale(1);
  376. }
  377. 50% {
  378. opacity: 1;
  379. transform: scale(1.15);
  380. }
  381. }
  382. ::v-deep .close-btn {
  383. position: absolute;
  384. top: 10px;
  385. right: 12px;
  386. color: #8da6c7;
  387. cursor: pointer;
  388. font-size: 16px;
  389. transition: color 0.3s;
  390. line-height: 1;
  391. z-index: 10;
  392. }
  393. ::v-deep .close-btn:hover {
  394. color: #ffffff;
  395. }
  396. ::v-deep .custom-info-card {
  397. position: relative;
  398. background: rgba(10, 15, 24, 0.95);
  399. border-radius: 10px;
  400. padding: 12px 16px;
  401. min-width: 200px;
  402. border: 1px solid rgba(255, 255, 255, 0.1);
  403. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
  404. color: #fff;
  405. }
  406. ::v-deep .card-header {
  407. display: flex;
  408. align-items: center;
  409. margin-bottom: 10px;
  410. }
  411. ::v-deep .status-dot {
  412. width: 18px;
  413. height: 18px;
  414. border-radius: 50%;
  415. display: flex;
  416. justify-content: center;
  417. align-items: center;
  418. margin-right: 8px;
  419. font-size: 12px;
  420. padding: 12px;
  421. box-sizing: border-box;
  422. }
  423. ::v-deep .status-dot span {
  424. transform: scale(0.75);
  425. }
  426. ::v-deep .status-text {
  427. font-size: 15px;
  428. font-weight: bold;
  429. }
  430. ::v-deep .info-line {
  431. display: flex;
  432. margin-bottom: 6px;
  433. font-size: 13px;
  434. align-items: center;
  435. }
  436. ::v-deep .label {
  437. color: #8da6c7;
  438. white-space: nowrap;
  439. }
  440. ::v-deep .value {
  441. color: #ffffff;
  442. }
  443. ::v-deep .digital {
  444. font-family: 'Consolas', monospace;
  445. }
  446. .map-legend {
  447. position: absolute;
  448. bottom: 30px;
  449. right: 40px;
  450. background: rgba(5, 22, 45, 0.9);
  451. border: 1px solid #1e4d8e;
  452. padding: 15px;
  453. border-radius: 6px;
  454. z-index: 100;
  455. }
  456. .legend-title {
  457. color: #fff;
  458. font-size: 14px;
  459. margin-bottom: 12px;
  460. border-bottom: 1px solid #1e4d8e;
  461. padding-bottom: 8px;
  462. }
  463. .legend-item {
  464. display: flex;
  465. align-items: center;
  466. margin-bottom: 10px;
  467. cursor: pointer;
  468. transition: 0.3s;
  469. }
  470. .legend-item.is-inactive {
  471. opacity: 0.2;
  472. filter: grayscale(1);
  473. }
  474. .legend-dot {
  475. margin-right: 10px;
  476. display: flex;
  477. justify-content: center;
  478. align-items: center;
  479. }
  480. .legend-dot:not(.is-status-wrapper) {
  481. width: 20px;
  482. height: 20px;
  483. border-radius: 50%;
  484. }
  485. .legend-dot span {
  486. display: inline-block;
  487. width: 20px;
  488. height: 20px;
  489. font-size: 12px;
  490. color: #FFF;
  491. text-align: center;
  492. transform: scale(0.75);
  493. line-height: 20px;
  494. }
  495. .legend-dot.special-route {
  496. position: relative;
  497. overflow: visible !important;
  498. z-index: 1;
  499. border: none !important;
  500. border-radius: 50%;
  501. }
  502. .legend-dot.special-route span {
  503. position: relative;
  504. z-index: 3;
  505. font-weight: bold;
  506. }
  507. .legend-dot.special-route::after {
  508. content: "";
  509. position: absolute;
  510. top: 50%;
  511. left: 50%;
  512. width: 150%;
  513. height: 3px;
  514. background-color: inherit;
  515. opacity: 0.8;
  516. transform: translate(-50%, -50%) rotate(-45deg);
  517. pointer-events: none;
  518. z-index: 0;
  519. }
  520. .legend-dot.special-route::before {
  521. content: "";
  522. position: absolute;
  523. top: 0;
  524. left: 0;
  525. width: 100%;
  526. height: 100%;
  527. border-radius: 50%;
  528. z-index: 2;
  529. background-color: inherit;
  530. opacity: 1;
  531. }
  532. .legend-label {
  533. flex: 1;
  534. color: #d0d9e2;
  535. font-size: 13px;
  536. }
  537. .legend-status {
  538. font-size: 11px;
  539. color: #5b7da8;
  540. }
  541. .all-select {
  542. border-bottom: 1px solid rgba(255, 255, 255, 0.2);
  543. padding-bottom: 12px;
  544. margin-bottom: 12px !important;
  545. }
  546. .all-select .legend-dot {
  547. border-radius: 2px;
  548. transition: all 0.3s;
  549. width: 10px;
  550. height: 10px;
  551. }
  552. .legend-dot.is-status-wrapper {
  553. width: 28px;
  554. height: 28px;
  555. background-color: transparent !important;
  556. box-shadow: none !important;
  557. border: none !important;
  558. margin-right: 0;
  559. transform: translateX(-15%);
  560. }
  561. .status-icon {
  562. width: 100%;
  563. height: 100%;
  564. object-fit: contain;
  565. display: block;
  566. }
  567. </style>