TongzhouTrafficMap.vue 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756
  1. <template>
  2. <div class="map-wrapper" :style="mapCssVars">
  3. <div ref="mapContainer" class="map-container"></div>
  4. <div class="map-legend" :style="privateStyle.legend" v-if="(!mode || mode === '路口')"
  5. :class="{ 'legend-hidden': !legendVisible }">
  6. <div class="legend-header">
  7. <div class="legend-title">图例</div>
  8. <div class="legend-close-btn" @click="toggleLegend">✕</div>
  9. </div>
  10. <div class="legend-list">
  11. <div class="legend-item all-select" @click="toggleAll">
  12. <div class="legend-dot"
  13. :style="{ backgroundColor: isAllSelected ? '#fff' : 'transparent', border: '1px solid #fff' }"></div>
  14. <div class="legend-label" style="font-weight: bold;">全选</div>
  15. </div>
  16. <div v-for="item in legendStatusConfig" class="legend-item" @click="toggleRouteVisible(item.name)" :key="item.name"
  17. :class="{ 'is-inactive': !activeLegends.includes(item.name) }">
  18. <div class="legend-dot"
  19. :style="{ backgroundColor: ['离线', '降级', '故障'].includes(item.name) ? 'transparent' : item.color }"
  20. :class="{ 'special-route': ['干线协调', '勤务路线'].includes(item.name), 'is-status-wrapper': ['离线', '降级', '故障'].includes(item.name) }">
  21. <span v-if="!['离线', '降级', '故障'].includes(item.name)">
  22. {{ item.name.charAt(0) }}
  23. </span>
  24. <img v-else
  25. :src="require(`@/assets/images/icon_${item.name === '离线' ? 'lixian' : item.name === '降级' ? 'jiangji' : 'guzhang'}.png`)"
  26. class="status-icon" />
  27. </div>
  28. <div class="legend-label">{{ item.name }}</div>
  29. </div>
  30. </div>
  31. </div>
  32. <div class="legend-show-btn" v-if="(!mode || mode === '路口') && !legendVisible" @click="toggleLegend" :style="legendShowBtnStyle">
  33. <div class="legend-show-icon">☰</div>
  34. </div>
  35. </div>
  36. </template>
  37. <script>
  38. import AMapLoader from '@amap/amap-jsapi-loader';
  39. export default {
  40. name: "TrafficMap",
  41. props: {
  42. amapKey: { type: String, default: '您的Key' },
  43. securityJsCode: { type: String, default: '您的安全密钥' },
  44. mode: { type: String, default: '', validator: (value) => ['', '路口', '干线', '特勤'].includes(value) }
  45. },
  46. data() {
  47. return {
  48. AMap: null,
  49. map: null,
  50. infoWindow: null,
  51. routeGroups: {},
  52. dotMarkers: [],
  53. privateStyle: {
  54. legend: {}
  55. },
  56. legendVisible: true,
  57. activeLegends: ["中心计划", "干线协调", "勤务路线", "定周期控制", "感应控制", "自适应控制", "手动控制", "特殊控制", "离线", "降级", "故障"],
  58. isComponentDestroyed: false,
  59. drawSeq: 0,
  60. driving: null,
  61. infoCloseTimer: null,
  62. activeInfoWindowId: null,
  63. isInfoWindowHovered: false,
  64. statusConfig: [
  65. { name: "中心计划", color: "#004CDE", type: "normal" },
  66. { name: "干线协调", color: "#13C373", type: "route" },
  67. { name: "勤务路线", color: "#BC301D", type: "route" },
  68. { name: "定周期控制", color: "#3296FA", type: "normal" },
  69. { name: "感应控制", color: "#FF864C", type: "normal" },
  70. { name: "自适应控制", color: "#9F6EFE", type: "normal" },
  71. { name: "手动控制", color: "#EB9F36", type: "normal" },
  72. { name: "特殊控制", color: "#A26218", type: "normal" },
  73. { name: "离线", color: "#7A7A7A", type: "abnormal" },
  74. { name: "降级", color: "#D9C13B", type: "abnormal" },
  75. { name: "故障", color: "#FF3938", type: "abnormal" }
  76. ],
  77. intersectionData: [],
  78. statusIntersections: {},
  79. currentZoomSize: 14, // 保存当前的动态尺寸
  80. };
  81. },
  82. mounted() {
  83. this.isComponentDestroyed = false;
  84. this.loadMapData().then(() => {
  85. this.classifyIntersectionsByStatus();
  86. this.updateMapByMode();
  87. this.initAMap();
  88. this.storeStatusCoordsToLocalStorage();
  89. });
  90. if (this.$route.path === '/home' || this.$route.path === '/watch') {
  91. this.privateStyle.legend = { right: "25%" };
  92. }
  93. },
  94. watch: {
  95. mode: {
  96. handler() {
  97. this.updateMapByMode();
  98. this.updateMapDisplay();
  99. },
  100. immediate: false
  101. }
  102. },
  103. beforeDestroy() {
  104. // 1. 立即设置销毁状态
  105. this.isComponentDestroyed = true;
  106. this.drawSeq += 1;
  107. // 2. 关闭弹窗
  108. if (this.infoWindow) {
  109. try {
  110. this.infoWindow.close();
  111. } catch (e) {
  112. console.warn('关闭信息窗口时出错:', e);
  113. }
  114. this.infoWindow = null;
  115. }
  116. // 3. 清理覆盖物引用
  117. if (this.routeGroups) {
  118. Object.values(this.routeGroups).forEach(overlays => {
  119. if (Array.isArray(overlays)) {
  120. overlays.forEach(o => {
  121. try {
  122. if (o.setMap) o.setMap(null);
  123. } catch (e) {
  124. console.warn('清理覆盖物时出错:', e);
  125. }
  126. });
  127. }
  128. });
  129. this.routeGroups = {};
  130. }
  131. // 4. 销毁地图实例并清空引用
  132. if (this.map) {
  133. try {
  134. this.map.destroy();
  135. } catch (e) {
  136. console.warn('销毁地图实例时出错:', e);
  137. }
  138. this.map = null;
  139. }
  140. // 5. 清理其他引用
  141. this.AMap = null;
  142. this.driving = null;
  143. if (this.infoCloseTimer) {
  144. clearTimeout(this.infoCloseTimer);
  145. this.infoCloseTimer = null;
  146. }
  147. this.activeInfoWindowId = null;
  148. this.isInfoWindowHovered = false;
  149. },
  150. computed: {
  151. isAllSelected() {
  152. return this.activeLegends.length === this.statusConfig.length;
  153. },
  154. isHomePage() {
  155. return this.$route && (this.$route.path === '/home' || this.$route.path === '/surve');
  156. },
  157. legendShowBtnStyle() {
  158. if (!this.isHomePage) return {};
  159. const right = (this.privateStyle.legend && this.privateStyle.legend.right) ? this.privateStyle.legend.right : '25%';
  160. return { right, borderRadius: '6px' };
  161. },
  162. legendStatusConfig() {
  163. if (!this.mode) return this.statusConfig;
  164. if (this.mode === '路口') {
  165. return this.statusConfig.filter(item => !['干线协调', '勤务路线'].includes(item.name));
  166. }
  167. return [];
  168. },
  169. // 自动计算并下发给所有 CSS 的变量字典
  170. mapCssVars() {
  171. const size = this.currentZoomSize;
  172. const specialSize = Math.max(16, size * 1.5);
  173. return {
  174. '--dot-size': `${size}px`,
  175. '--dot-padding': size >= 14 ? '2px' : '0px',
  176. '--text-display': size >= 14 ? 'flex' : 'none',
  177. '--text-size': `${Math.max(10, size - 2)}px`,
  178. '--special-size': `${specialSize}px`
  179. };
  180. }
  181. },
  182. methods: {
  183. /**
  184. * 动态加载地图数据
  185. * @returns {Promise<void>}
  186. */
  187. async loadMapData() {
  188. try {
  189. const mapDataModule = await import('@/mock/map_data_gaode.json');
  190. this.intersectionData = mapDataModule.default || [];
  191. console.log('地图数据加载成功,共', this.intersectionData.length, '个路口');
  192. } catch (error) {
  193. console.error('地图数据加载失败:', error);
  194. this.intersectionData = [];
  195. }
  196. },
  197. /**
  198. * 检查地图环境是否安全可用
  199. * @returns {boolean}
  200. */
  201. isMapReady() {
  202. return !this.isComponentDestroyed && this.map && typeof this.map.add === 'function';
  203. },
  204. /**
  205. * 将真实路口数据按状态类型分类
  206. */
  207. classifyIntersectionsByStatus() {
  208. const remainingData = this.intersectionData;
  209. const maxAbnormalCount = 4;
  210. const abnormalTotal = maxAbnormalCount * 3;
  211. const normalData = remainingData.slice(0, remainingData.length - abnormalTotal);
  212. const abnormalData = remainingData.slice(remainingData.length - abnormalTotal);
  213. const normalChunk = Math.ceil(normalData.length / 6);
  214. this.statusIntersections = {
  215. "中心计划": normalData.slice(0, normalChunk),
  216. "干线协调": [],
  217. "勤务路线": [],
  218. "定周期控制": normalData.slice(normalChunk, normalChunk * 2),
  219. "感应控制": normalData.slice(normalChunk * 2, normalChunk * 3),
  220. "自适应控制": normalData.slice(normalChunk * 3, normalChunk * 4),
  221. "手动控制": normalData.slice(normalChunk * 4, normalChunk * 5),
  222. "特殊控制": normalData.slice(normalChunk * 5),
  223. "离线": abnormalData.slice(0, maxAbnormalCount),
  224. "降级": abnormalData.slice(maxAbnormalCount, maxAbnormalCount * 2),
  225. "故障": abnormalData.slice(maxAbnormalCount * 2, maxAbnormalCount * 3)
  226. };
  227. },
  228. /**
  229. * 根据模式更新地图显示
  230. */
  231. updateMapByMode() {
  232. switch (this.mode) {
  233. case '路口':
  234. this.activeLegends = ["中心计划", "定周期控制", "感应控制", "自适应控制", "手动控制", "特殊控制", "离线", "降级", "故障"];
  235. break;
  236. case '干线':
  237. this.activeLegends = ["干线协调"];
  238. break;
  239. case '特勤':
  240. this.activeLegends = ["勤务路线"];
  241. break;
  242. default:
  243. this.activeLegends = this.statusConfig.map(item => item.name);
  244. }
  245. },
  246. /**
  247. * 更新地图显示状态
  248. */
  249. updateMapDisplay() {
  250. if (this.infoWindow) this.infoWindow.close();
  251. if (!this.isMapReady()) return;
  252. Object.keys(this.routeGroups).forEach(name => {
  253. const overlays = this.routeGroups[name];
  254. if (overlays && overlays.length > 0) {
  255. if (this.activeLegends.includes(name)) {
  256. this.map.add(overlays);
  257. } else {
  258. this.map.remove(overlays);
  259. }
  260. }
  261. });
  262. },
  263. /**
  264. * 初始化高德地图
  265. * @returns {Promise<void>}
  266. */
  267. async initAMap() {
  268. if (this.isComponentDestroyed) return;
  269. window._AMapSecurityConfig = { securityJsCode: this.securityJsCode };
  270. try {
  271. const AMap = await AMapLoader.load({
  272. key: this.amapKey,
  273. version: "2.0",
  274. plugins: ['AMap.Driving']
  275. });
  276. if (this.isComponentDestroyed) return;
  277. this.AMap = AMap;
  278. this.map = new AMap.Map(this.$refs.mapContainer, {
  279. zoom: 15,
  280. mapStyle: "amap://styles/darkblue",
  281. center: [116.663, 39.905]
  282. });
  283. this.driving = new AMap.Driving({ map: null, hideMarkers: true });
  284. this.map.on('complete', () => {
  285. if (!this.isComponentDestroyed) {
  286. this.drawStaticRoutes();
  287. }
  288. });
  289. this.map.on('zoomchange', () => {
  290. if (!this.isComponentDestroyed) {
  291. this.currentZoomSize = this.getDotSizeByZoom();
  292. }
  293. });
  294. } catch (err) {
  295. console.error('地图加载失败:', err);
  296. }
  297. },
  298. /**
  299. * 绘制静态路线和标记
  300. * @returns {Promise<void>}
  301. */
  302. async drawStaticRoutes() {
  303. if (!this.isMapReady()) return;
  304. this.drawSeq += 1;
  305. const drawSeq = this.drawSeq;
  306. this.clearAllRouteOverlays();
  307. const realRouteConfigs = {
  308. "干线协调": [
  309. { start: [116.6421, 39.9272], end: [116.6623, 39.9272], color: "#13C373", trunkId: 'trunk_1', trunkName: '古城南路与古城大街' },
  310. { start: [116.6623, 39.9272], end: [116.6825, 39.9272], color: "#13C373", trunkId: 'trunk_2', trunkName: '古城西路东口南一过街' },
  311. { start: [116.6426, 39.9221], end: [116.6628, 39.9221], color: "#13C373", trunkId: 'trunk_3', trunkName: '古城大街与古城北路' },
  312. { start: [116.6628, 39.9221], end: [116.683, 39.9221], color: "#13C373", trunkId: 'trunk_4', trunkName: '八角北路与八角东街' },
  313. { start: [116.6432, 39.9171], end: [116.66325, 39.9171], color: "#13C373", trunkId: 'trunk_5', trunkName: '古城西路与古城大街' },
  314. { start: [116.66325, 39.9171], end: [116.6833, 39.9171], color: "#13C373", trunkId: 'trunk_6', trunkName: '张台路与湖亦路路口' },
  315. ],
  316. "勤务路线": [
  317. { start: [116.6445, 39.8980], end: [116.6850, 39.8980], color: "#BC301D", dutyState: 'pending' },
  318. { start: [116.6850, 39.8980], end: [116.7250, 39.8980], color: "#BC301D", dutyState: 'active', progress: 0.45 },
  319. { start: [116.7250, 39.8980], end: [116.7650, 39.8980], color: "#BC301D", dutyState: 'done' }
  320. ]
  321. };
  322. for (const config of this.statusConfig) {
  323. if (this.isComponentDestroyed || drawSeq !== this.drawSeq) return;
  324. if (!realRouteConfigs[config.name]) {
  325. const intersections = this.statusIntersections[config.name] || [];
  326. const markers = intersections.map(item =>
  327. this.createTrafficLightMarker([item["位置-经度"], item["位置-纬度"]], {
  328. ...config,
  329. id: item["路口编号"],
  330. road: item["路口名称"] || '规划路口'
  331. })
  332. ).filter(Boolean);
  333. this.routeGroups[config.name] = markers;
  334. if (this.activeLegends.includes(config.name)) this.map.add(markers);
  335. continue;
  336. }
  337. this.routeGroups[config.name] = [];
  338. const lines = realRouteConfigs[config.name] || [];
  339. const trunkSegments = [];
  340. for (let lineIdx = 0; lineIdx < lines.length; lineIdx += 1) {
  341. if (this.isComponentDestroyed || drawSeq !== this.drawSeq) return;
  342. const line = lines[lineIdx];
  343. let path = null;
  344. try {
  345. path = await this.searchDrivingPathWithRetry(line.start, line.end);
  346. } catch (e) {
  347. path = null;
  348. }
  349. const overlays = this.buildRouteOverlaysFromPath({
  350. config,
  351. configName: config.name,
  352. line,
  353. lineIdx,
  354. path
  355. });
  356. if (config.name === '干线协调' && overlays.length > 0) {
  357. const name = line.trunkName || ('干线' + (lineIdx + 1));
  358. trunkSegments.push({
  359. id: line.trunkId || ('trunk_' + (lineIdx + 1)),
  360. label: name,
  361. intersections: Array.from({ length: 6 }, (_, k) => name + '_路口' + (k + 1)),
  362. distances: Array.from({ length: 6 }, (_, k) => k * 500),
  363. _lineIdx: lineIdx
  364. });
  365. }
  366. if (this.isComponentDestroyed || drawSeq !== this.drawSeq) return;
  367. if (overlays.length > 0) {
  368. this.routeGroups[config.name].push(...overlays);
  369. if (this.activeLegends.includes(config.name)) this.map.add(overlays);
  370. }
  371. await this.sleep(80);
  372. }
  373. if (config.name === '干线协调' && trunkSegments.length > 0) {
  374. this.$emit('bindTrunkMenuTree', trunkSegments);
  375. }
  376. }
  377. },
  378. /**
  379. * 清除所有路线覆盖物
  380. */
  381. clearAllRouteOverlays() {
  382. if (!this.isMapReady()) return;
  383. try {
  384. if (this.infoWindow) this.infoWindow.close();
  385. } catch (e) {
  386. void e;
  387. }
  388. Object.values(this.routeGroups || {}).forEach(overlays => {
  389. if (!Array.isArray(overlays) || overlays.length === 0) return;
  390. try {
  391. this.map.remove(overlays);
  392. } catch (e) {
  393. void e;
  394. }
  395. overlays.forEach(o => {
  396. try {
  397. if (o && typeof o.setMap === 'function') o.setMap(null);
  398. } catch (e) {
  399. void e;
  400. }
  401. });
  402. });
  403. this.routeGroups = {};
  404. this.dotMarkers = [];
  405. },
  406. /**
  407. * 休眠函数
  408. * @param {number} ms - 休眠毫秒数
  409. * @returns {Promise<void>}
  410. */
  411. sleep(ms) {
  412. return new Promise(resolve => setTimeout(resolve, ms));
  413. },
  414. /**
  415. * 带重试机制的路径规划
  416. * @param {Array<number>} start - 起点坐标 [lng, lat]
  417. * @param {Array<number>} end - 终点坐标 [lng, lat]
  418. * @returns {Promise<Array>} 路径点数组
  419. */
  420. async searchDrivingPathWithRetry(start, end) {
  421. if (!this.AMap || !this.driving || typeof this.driving.search !== 'function') {
  422. throw new Error('Driving not ready');
  423. }
  424. const maxAttempts = 3;
  425. let lastErr = null;
  426. for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
  427. try {
  428. return await this.withTimeout(this.searchDrivingPathOnce(start, end), 8000);
  429. } catch (e) {
  430. lastErr = e;
  431. await this.sleep(250 * attempt);
  432. }
  433. }
  434. throw lastErr || new Error('Driving search failed');
  435. },
  436. /**
  437. * 单次路径规划
  438. * @param {Array<number>} start - 起点坐标 [lng, lat]
  439. * @param {Array<number>} end - 终点坐标 [lng, lat]
  440. * @returns {Promise<Array>} 路径点数组
  441. */
  442. searchDrivingPathOnce(start, end) {
  443. return new Promise((resolve, reject) => {
  444. this.driving.search(start, end, (status, result) => {
  445. const route = result && result.routes && result.routes[0];
  446. if (status === 'complete' && route && Array.isArray(route.steps)) {
  447. const fullPath = [];
  448. route.steps.forEach(step => {
  449. if (step && Array.isArray(step.path)) fullPath.push(...step.path);
  450. });
  451. if (fullPath.length >= 2) resolve(fullPath);
  452. else reject(new Error('empty_path'));
  453. return;
  454. }
  455. reject(new Error(typeof status === 'string' ? status : 'driving_error'));
  456. });
  457. });
  458. },
  459. /**
  460. * 为Promise添加超时机制
  461. * @param {Promise} promise - 要执行的Promise
  462. * @param {number} timeoutMs - 超时毫秒数
  463. * @returns {Promise} 带超时的Promise
  464. */
  465. withTimeout(promise, timeoutMs) {
  466. return Promise.race([
  467. promise,
  468. new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs))
  469. ]);
  470. },
  471. /**
  472. * 从路径构建路线覆盖物
  473. * @param {Object} params - 参数对象
  474. * @param {Object} params.config - 配置对象
  475. * @param {string} params.configName - 配置名称
  476. * @param {Object} params.line - 路线配置
  477. * @param {number} params.lineIdx - 路线索引
  478. * @param {Array} params.path - 路径点数组
  479. * @returns {Array} 覆盖物数组
  480. */
  481. buildRouteOverlaysFromPath({ config, configName, line, lineIdx, path }) {
  482. if (!this.AMap || !this.map) return [];
  483. let basePath = path;
  484. if (!Array.isArray(basePath) || basePath.length < 2) {
  485. basePath = this.buildFallbackLinePath(line.start, line.end, 30);
  486. }
  487. const offsetVal = 0;
  488. const applyOffset = (p) => {
  489. const lng = p.lng || (p.getLng ? p.getLng() : (Array.isArray(p) ? Number(p[0]) : 0));
  490. const lat = p.lat || (p.getLat ? p.getLat() : (Array.isArray(p) ? Number(p[1]) : 0));
  491. return [lng + offsetVal, lat + offsetVal];
  492. };
  493. const allSegments = this.extractMainStraightSegments(basePath);
  494. const segments = configName === '干线协调'
  495. ? allSegments.slice(0, 1)
  496. : allSegments;
  497. const overlays = [];
  498. segments.forEach((rawSegmentPath, segmentIdx) => {
  499. if (!Array.isArray(rawSegmentPath) || rawSegmentPath.length < 2) return;
  500. const segmentPath = rawSegmentPath.map(p => applyOffset(p));
  501. if (configName === '勤务路线' && line.dutyState) {
  502. const state = line.dutyState; // 'pending' | 'active' | 'done'
  503. const progress = (state === 'active') ? Math.min(Math.max(Number(line.progress) || 0.5, 0), 1) : (state === 'done' ? 1 : 0);
  504. // 计算整段累计距离
  505. const dists = [0];
  506. let totalDist = 0;
  507. for (let j = 0; j < segmentPath.length - 1; j++) {
  508. totalDist += this.calcApproxDistance(segmentPath[j], segmentPath[j + 1]);
  509. dists.push(totalDist);
  510. }
  511. const splitDist = totalDist * progress;
  512. // 找切割点坐标(线性插值)
  513. let splitIdx = segmentPath.length - 1;
  514. let splitPoint = segmentPath[segmentPath.length - 1];
  515. for (let j = 0; j < dists.length - 1; j++) {
  516. if (splitDist >= dists[j] && splitDist <= dists[j + 1]) {
  517. const ratio = dists[j + 1] === dists[j] ? 0 : (splitDist - dists[j]) / (dists[j + 1] - dists[j]);
  518. const p1 = segmentPath[j], p2 = segmentPath[j + 1];
  519. splitPoint = [
  520. Number(p1[0]) + (Number(p2[0]) - Number(p1[0])) * ratio,
  521. Number(p1[1]) + (Number(p2[1]) - Number(p1[1])) * ratio
  522. ];
  523. splitIdx = j;
  524. break;
  525. }
  526. }
  527. // 已过段(灰色)
  528. const passedPath = [...segmentPath.slice(0, splitIdx + 1), splitPoint];
  529. if (passedPath.length >= 2) {
  530. overlays.push(new this.AMap.Polyline({
  531. path: passedPath,
  532. strokeColor: '#5A5A5A',
  533. strokeWeight: 6,
  534. strokeOpacity: 0.45,
  535. zIndex: 15
  536. }));
  537. }
  538. // 未过段(原红色),done 状态不绘制
  539. const remainPath = [splitPoint, ...segmentPath.slice(splitIdx + 1)];
  540. if (state !== 'done' && remainPath.length >= 2) {
  541. overlays.push(new this.AMap.Polyline({
  542. path: remainPath,
  543. strokeColor: line.color,
  544. strokeWeight: 6,
  545. strokeOpacity: 0.8,
  546. zIndex: 15
  547. }));
  548. }
  549. // 执行中:进度点脉冲 marker
  550. if (state === 'active') {
  551. overlays.push(new this.AMap.Marker({
  552. position: splitPoint,
  553. zIndex: 150,
  554. offset: new this.AMap.Pixel(-12, -12),
  555. bubble: true,
  556. content: `<div class="duty-progress-node" style="width:24px;height:24px;border-radius:50%;background:#BC301D;border:3px solid #fff;box-shadow:0 0 0 3px rgba(188,48,29,0.4);display:flex;justify-content:center;align-items:center;cursor:default;"><div style="width:8px;height:8px;border-radius:50%;background:#fff;"></div></div>`
  557. }));
  558. }
  559. // 方向箭头:已过段灰色低透明,未过段正常
  560. const targetSpacing = 0.0036;
  561. let arrowDist = targetSpacing / 2;
  562. while (arrowDist < totalDist) {
  563. let foundIdx = 0;
  564. for (let j = 0; j < dists.length - 1; j++) {
  565. if (arrowDist >= dists[j] && arrowDist <= dists[j + 1]) { foundIdx = j; break; }
  566. }
  567. const p1 = segmentPath[foundIdx], p2 = segmentPath[foundIdx + 1];
  568. if (p1 && p2) {
  569. const ratio = (arrowDist - dists[foundIdx]) / (dists[foundIdx + 1] - dists[foundIdx]);
  570. const midLng = Number(p1[0]) + (Number(p2[0]) - Number(p1[0])) * ratio;
  571. const midLat = Number(p1[1]) + (Number(p2[1]) - Number(p1[1])) * ratio;
  572. const isPassed = arrowDist <= splitDist;
  573. const rotation = this.calcBearingDeg(p1, p2) - 90;
  574. overlays.push(new this.AMap.Marker({
  575. position: [midLng, midLat],
  576. content: `<div style="transform:rotate(${rotation}deg);width:20px;height:10px;display:flex;align-items:center;pointer-events:none;opacity:${isPassed ? 0.2 : 0.85};filter:${isPassed ? 'grayscale(1)' : 'none'};"><img src="${require('@/assets/map/direction.png')}" style="width:100%;height:auto;" /></div>`,
  577. offset: new this.AMap.Pixel(-10, -5),
  578. zIndex: 20,
  579. bubble: true
  580. }));
  581. }
  582. arrowDist += targetSpacing;
  583. }
  584. // 路口圆点:已过置灰,未过正常
  585. const indices = this.pickEvenlySpacedIndices(segmentPath.length, 6);
  586. for (let i = 0; i < indices.length; i++) {
  587. const idx = indices[i];
  588. const p = segmentPath[idx];
  589. const lng = Number(p[0]), lat = Number(p[1]);
  590. if (Number.isNaN(lng) || Number.isNaN(lat)) continue;
  591. const dotDist = dists[idx] || 0;
  592. let markerType = 'normal';
  593. if (i === 0) markerType = 'start';
  594. else if (i === indices.length - 1) markerType = 'end';
  595. const isPastDot = dotDist <= splitDist;
  596. const dotConfig = isPastDot
  597. ? { ...config, color: '#5A5A5A', id: `MOCK-D-${lineIdx}-${segmentIdx}-${idx}`, road: `勤务路线路口-${lineIdx}-${segmentIdx}-${idx}` }
  598. : { ...config, id: `MOCK-D-${lineIdx}-${segmentIdx}-${idx}`, road: `勤务路线路口-${lineIdx}-${segmentIdx}-${idx}` };
  599. const dotType = isPastDot && markerType === 'normal' ? 'passed' : markerType;
  600. const marker = this.createTrafficLightMarker([lng, lat], dotConfig, dotType);
  601. if (marker) overlays.push(marker);
  602. }
  603. } else {
  604. // 非勤务路线 或 无 dutyState:原有逻辑
  605. const polyline = new this.AMap.Polyline({
  606. path: segmentPath,
  607. strokeColor: line.color,
  608. strokeWeight: 6,
  609. strokeOpacity: 0.8,
  610. zIndex: 15
  611. });
  612. overlays.push(polyline);
  613. const totalPoints = segmentPath.length;
  614. const pathDistances = [0];
  615. let totalPathDist = 0;
  616. for (let j = 0; j < totalPoints - 1; j++) {
  617. const d = this.calcApproxDistance(segmentPath[j], segmentPath[j+1]);
  618. totalPathDist += d;
  619. pathDistances.push(totalPathDist);
  620. }
  621. if (configName !== '干线协调') {
  622. const targetSpacing = configName === '勤务路线' ? 0.0036 : 0.0018;
  623. let currentTargetDist = targetSpacing / 2;
  624. while (currentTargetDist < totalPathDist) {
  625. let foundIdx = 0;
  626. for (let j = 0; j < pathDistances.length - 1; j++) {
  627. if (currentTargetDist >= pathDistances[j] && currentTargetDist <= pathDistances[j + 1]) {
  628. foundIdx = j;
  629. break;
  630. }
  631. }
  632. const p1 = segmentPath[foundIdx];
  633. const p2 = segmentPath[foundIdx + 1];
  634. if (p1 && p2) {
  635. const ratio = (currentTargetDist - pathDistances[foundIdx]) / (pathDistances[foundIdx + 1] - pathDistances[foundIdx]);
  636. const midLng = Number(p1[0]) + (Number(p2[0]) - Number(p1[0])) * ratio;
  637. const midLat = Number(p1[1]) + (Number(p2[1]) - Number(p1[1])) * ratio;
  638. const bearing = this.calcBearingDeg(p1, p2);
  639. const rotation = bearing - 90;
  640. const directionMarker = new this.AMap.Marker({
  641. position: [midLng, midLat],
  642. content: `
  643. <div style="transform: rotate(${rotation}deg); width: 20px; height: 10px; display: flex; align-items: center; pointer-events: none; opacity: 0.85;">
  644. <img src="${require('@/assets/map/direction.png')}" style="width: 100%; height: auto;" />
  645. </div>
  646. `,
  647. offset: new this.AMap.Pixel(-10, -5),
  648. zIndex: 20,
  649. bubble: true
  650. });
  651. overlays.push(directionMarker);
  652. }
  653. currentTargetDist += targetSpacing;
  654. }
  655. }
  656. const indices = this.pickEvenlySpacedIndices(totalPoints, 6);
  657. for (let i = 0; i < indices.length; i++) {
  658. const idx = indices[i];
  659. const p = segmentPath[idx];
  660. const lng = Number(p[0]);
  661. const lat = Number(p[1]);
  662. if (Number.isNaN(lng) || Number.isNaN(lat)) continue;
  663. let markerType = 'normal';
  664. if (configName !== '干线协调') {
  665. if (i === 0) markerType = 'start';
  666. else if (i === indices.length - 1) markerType = 'end';
  667. }
  668. // 干线协调:id 用干线编号,road 用干线名称(与菜单标题一致)
  669. const markerId = (configName === '干线协调' && line.trunkId)
  670. ? `${line.trunkId}_point_${i + 1}`
  671. : `MOCK-${configName.charAt(0)}-${lineIdx}-${segmentIdx}-${idx}`;
  672. const markerRoad = (configName === '干线协调' && line.trunkName)
  673. ? line.trunkName
  674. : `${configName}路口-${lineIdx}-${segmentIdx}-${idx}`;
  675. const marker = this.createTrafficLightMarker([lng, lat], {
  676. ...config,
  677. id: markerId,
  678. road: markerRoad
  679. }, markerType);
  680. if (marker) overlays.push(marker);
  681. }
  682. } // end else
  683. }); // end segments.forEach
  684. return overlays;
  685. },
  686. /**
  687. * 均匀选取点的索引
  688. * @param {number} totalPoints - 总点数
  689. * @param {number} count - 要选取的点数
  690. * @returns {Array<number>} 索引数组
  691. */
  692. pickEvenlySpacedIndices(totalPoints, count) {
  693. const total = Math.max(Number(totalPoints) || 0, 0);
  694. const target = Math.max(Number(count) || 0, 0);
  695. if (total <= 0 || target <= 0) return [];
  696. if (target >= total) return Array.from({ length: total }, (_, i) => i);
  697. if (target === 1) return [0];
  698. return Array.from({ length: target }, (_, k) => Math.round((k * (total - 1)) / (target - 1)));
  699. },
  700. /**
  701. * 构建备选线路路径
  702. * @param {Array<number>} start - 起点坐标 [lng, lat]
  703. * @param {Array<number>} end - 终点坐标 [lng, lat]
  704. * @param {number} pointCount - 点数量
  705. * @returns {Array} 路径点数组
  706. */
  707. buildFallbackLinePath(start, end, pointCount) {
  708. const sLng = Number(start && start[0]);
  709. const sLat = Number(start && start[1]);
  710. const eLng = Number(end && end[0]);
  711. const eLat = Number(end && end[1]);
  712. const n = Math.max(Number(pointCount) || 2, 2);
  713. const path = [];
  714. if ([sLng, sLat, eLng, eLat].some(v => Number.isNaN(v))) return path;
  715. for (let i = 0; i < n; i += 1) {
  716. const t = n === 1 ? 0 : i / (n - 1);
  717. const lng = sLng + (eLng - sLng) * t;
  718. const lat = sLat + (eLat - sLat) * t;
  719. path.push({ lng, lat });
  720. }
  721. return path;
  722. },
  723. /**
  724. * 提取主要直线段
  725. * @param {Array} path - 路径点数组
  726. * @returns {Array} 直线段数组
  727. */
  728. extractMainStraightSegments(path) {
  729. const getCoord = (p) => {
  730. if (!p) return { lng: NaN, lat: NaN };
  731. if (Array.isArray(p)) return { lng: Number(p[0]), lat: Number(p[1]) };
  732. if (p.getLng) return { lng: p.getLng(), lat: p.getLat() };
  733. return { lng: Number(p.lng), lat: Number(p.lat) };
  734. };
  735. const points = (path || []).map(p => getCoord(p)).filter(p => !isNaN(p.lng) && !isNaN(p.lat));
  736. if (points.length < 2) return [];
  737. const thresholdDeg = 18;
  738. const segments = [];
  739. let curStart = 0;
  740. let curAngle = null;
  741. let curLen = 0;
  742. const pushSegment = (endIdx) => {
  743. if (endIdx <= curStart) return;
  744. segments.push({
  745. start: curStart,
  746. end: endIdx,
  747. len: curLen
  748. });
  749. };
  750. for (let i = 0; i < points.length - 1; i += 1) {
  751. const a = points[i];
  752. const b = points[i + 1];
  753. const len = this.calcApproxDistance(a, b);
  754. if (len <= 0) continue;
  755. const angle = this.calcBearingDeg(a, b);
  756. if (curAngle === null) {
  757. curStart = i;
  758. curAngle = angle;
  759. curLen = len;
  760. continue;
  761. }
  762. const diff = this.calcAngleDiffDeg(curAngle, angle);
  763. if (diff <= thresholdDeg) {
  764. curLen += len;
  765. } else {
  766. pushSegment(i);
  767. curStart = i;
  768. curAngle = angle;
  769. curLen = len;
  770. }
  771. }
  772. pushSegment(points.length - 2);
  773. if (segments.length === 0) return [points];
  774. segments.sort((s1, s2) => s2.len - s1.len);
  775. const maxLen = segments[0].len || 0;
  776. const filtered = segments.filter(s => s.len >= maxLen * 0.55).slice(0, 3);
  777. const finalSegments = filtered.map(s => points.slice(s.start, s.end + 2));
  778. return finalSegments.length > 0 ? finalSegments : [points];
  779. },
  780. /**
  781. * 从不同格式的点中提取经纬度数组 [lng, lat]
  782. * @param {Object|Array} p - 点对象或数组
  783. * @returns {Array<number>} 经纬度数组
  784. */
  785. _getCoords(p) {
  786. if (!p) return [0, 0];
  787. const lng = p.lng || (p.getLng ? p.getLng() : (Array.isArray(p) ? Number(p[0]) : 0));
  788. const lat = p.lat || (p.getLat ? p.getLat() : (Array.isArray(p) ? Number(p[1]) : 0));
  789. return [lng, lat];
  790. },
  791. /**
  792. * 计算两点之间的方位角(度)
  793. * @param {Object|Array} a - 起点
  794. * @param {Object|Array} b - 终点
  795. * @returns {number} 方位角
  796. */
  797. calcBearingDeg(a, b) {
  798. const [alng, alat] = this._getCoords(a);
  799. const [blng, blat] = this._getCoords(b);
  800. const latRad = ((alat + blat) / 2) * Math.PI / 180;
  801. const dx = (blng - alng) * Math.cos(latRad);
  802. const dy = (blat - alat);
  803. const mathAngle = Math.atan2(dy, dx) * 180 / Math.PI;
  804. return (90 - mathAngle + 360) % 360;
  805. },
  806. /**
  807. * 计算两个角度的差值
  808. * @param {number} a - 角度1
  809. * @param {number} b - 角度2
  810. * @returns {number} 角度差
  811. */
  812. calcAngleDiffDeg(a, b) {
  813. let diff = Math.abs(a - b);
  814. if (diff > 180) diff = 360 - diff;
  815. return diff;
  816. },
  817. /**
  818. * 计算两点之间的近似距离
  819. * @param {Object|Array} a - 点1
  820. * @param {Object|Array} b - 点2
  821. * @returns {number} 距离
  822. */
  823. calcApproxDistance(a, b) {
  824. const [alng, alat] = this._getCoords(a);
  825. const [blng, blat] = this._getCoords(b);
  826. const latRad = ((alat + blat) / 2) * Math.PI / 180;
  827. const dx = (blng - alng) * Math.cos(latRad);
  828. const dy = (blat - alat);
  829. return Math.sqrt(dx * dx + dy * dy);
  830. },
  831. /**
  832. * 根据当前缩放级别计算普通圆点的尺寸(px)
  833. * @returns {number} 圆点尺寸
  834. */
  835. getDotSizeByZoom() {
  836. if (!this.map) return 14;
  837. const zoom = this.map.getZoom();
  838. // 基准:zoom=15时是14px。每缩小一级减小3px。
  839. const size = 14 + (zoom - 15) * 3;
  840. return Math.min(Math.max(6, size), 28); // 最小值设为 6px
  841. },
  842. /**
  843. * 创建交通信号灯标记
  844. * @param {Array<number>|Object} position - 位置坐标
  845. * @param {Object} config - 配置对象
  846. * @param {string} [type='normal'] - 标记类型
  847. * @returns {Object|null} 标记对象
  848. */
  849. createTrafficLightMarker(position, config, type = 'normal') {
  850. if (!position || !config) return null;
  851. try {
  852. const lng = position.getLng ? position.getLng() : Number(position[0] !== undefined ? position[0] : position.lng);
  853. const lat = position.getLat ? position.getLat() : Number(position[1] !== undefined ? position[1] : position.lat);
  854. if (isNaN(lng) || isNaN(lat)) return null;
  855. let displayText = config.name ? config.name.charAt(0) : '';
  856. if (type === 'start') displayText = '起';
  857. if (type === 'end') displayText = '终';
  858. const isAbnormal = ["离线", "降级", "故障"].includes(config.name);
  859. const isRoute = ["干线协调", "勤务路线"].includes(config.name);
  860. const isStartEnd = type === 'start' || type === 'end';
  861. const isPassed = type === 'passed';
  862. let markerContent = '';
  863. // --- 核心优化:尺寸全部使用 var(--xxx) CSS变量接管 ---
  864. if (isStartEnd) {
  865. markerContent = `
  866. <div class="pure-light-node start-end-node" style="width: var(--special-size); height: calc(var(--special-size) + 6px); background: transparent; border: none; display: flex; justify-content: center; align-items: flex-end; cursor: pointer; transform-origin: bottom center;">
  867. <img src="${require(`@/assets/map/${type}.png`)}" style="width: 100%; height: auto; object-fit: contain; pointer-events: none;" />
  868. </div>
  869. `;
  870. } else if (isPassed) {
  871. markerContent = `
  872. <div class="pure-light-node" style="width: 14px; height: 14px; background: #5A5A5A; border: 1.5px solid rgba(255,255,255,0.25); box-sizing: content-box; display: flex; justify-content: center; align-items: center; color: #888; border-radius: 50%; cursor: pointer; padding: 2px; opacity: 0.55;">
  873. <span style="transform: scale(0.8); font-weight: bold; font-size: 12px;">勤</span>
  874. </div>
  875. `;
  876. } else if (isAbnormal) {
  877. const iconName = config.name === '离线' ? 'lixian' : config.name === '降级' ? 'jiangji' : 'guzhang';
  878. markerContent = `
  879. <div class="pure-light-node breathe" style="width: var(--special-size); height: var(--special-size); background: transparent; border: none; display: flex; justify-content: center; align-items: center; cursor: pointer; padding: 0;">
  880. <img src="${require(`@/assets/images/icon_${iconName}.png`)}" style="width: 100%; height: 100%; object-fit: contain;" />
  881. </div>
  882. `;
  883. } else {
  884. markerContent = `
  885. <div class="pure-light-node ${isRoute ? 'route-node' : ''}" style="width: var(--dot-size); height: var(--dot-size); background: ${config.color || '#999'}; box-shadow: ${isRoute ? 'none' : `0 0 8px ${config.color}`}; border: ${isRoute ? 'none' : '1.5px solid rgba(255,255,255,0.7)'}; box-sizing: border-box; display: flex; justify-content: center; align-items: center; color: #fff; border-radius: 50%; cursor: pointer; padding: var(--dot-padding);">
  886. <span style="display: var(--text-display); transform: scale(0.8); font-weight: bold; font-size: var(--text-size);">${displayText}</span>
  887. </div>
  888. `;
  889. }
  890. const marker = new this.AMap.Marker({
  891. position: [lng, lat],
  892. zIndex: isStartEnd ? 120 : (isAbnormal ? 110 : 100),
  893. content: markerContent,
  894. // 核心优化:高德 2.0 原生支持 anchor 属性。代替手动计算 Offset!
  895. anchor: isStartEnd ? 'bottom-center' : 'center',
  896. extData: {
  897. ...config,
  898. position: [lng, lat],
  899. statusColor: config.color || '#999',
  900. statusLabel: config.name,
  901. road: config.road || '规划路口',
  902. timestamp: Date.now(),
  903. time: this.formatEventTime(Date.now())
  904. }
  905. });
  906. marker.on('click', (e) => {
  907. if (this.isComponentDestroyed) return;
  908. const extData = e.target.getExtData();
  909. if (this.$route && this.$route.path === '/home') {
  910. this.cancelCloseInfoWindow();
  911. this.openLightInfo(extData, e.lnglat);
  912. }
  913. const pixel = this.map.lngLatToContainer(e.lnglat);
  914. this.$emit('map-crossing-click', extData, e.lnglat, pixel);
  915. });
  916. marker.on('mouseover', (e) => {
  917. if (this.isComponentDestroyed) return;
  918. if (this.$route && this.$route.path === '/home') {
  919. this.cancelCloseInfoWindow();
  920. this.openLightInfo(e.target.getExtData(), e.lnglat);
  921. }
  922. const pixel = this.map.lngLatToContainer(e.lnglat);
  923. this.$emit('map-crossing-mouseover', e.target.getExtData(), e.lnglat, pixel);
  924. });
  925. marker.on('mouseout', (e) => {
  926. if (this.isComponentDestroyed) return;
  927. if (this.$route && this.$route.path === '/home') {
  928. this.scheduleCloseInfoWindow();
  929. }
  930. this.$emit('map-crossing-mouseout', e.target.getExtData());
  931. });
  932. return marker;
  933. } catch (e) {
  934. console.warn('创建标记时出错:', e);
  935. return null;
  936. }
  937. },
  938. /**
  939. * 打开信号灯信息窗口
  940. * @param {Object} data - 信号灯数据
  941. * @param {Array<number>} position - 位置坐标
  942. */
  943. openLightInfo(data, position) {
  944. if (!this.isMapReady()) return;
  945. const infoWindowId = `info-window-${Date.now()}`;
  946. this.activeInfoWindowId = infoWindowId;
  947. const isAbnormal = ["离线", "降级", "故障"].includes(data.name);
  948. const deviceStatusText = isAbnormal ? data.name : '正常';
  949. const alarmInfoText = isAbnormal ? this.getAlarmInfoText(data.name) : '';
  950. const eventTimeText = this.formatEventTime(data.timestamp || data.time);
  951. let statusDotContent = '';
  952. if (isAbnormal) {
  953. const iconName = data.name === '离线' ? 'lixian' : data.name === '降级' ? 'jiangji' : 'guzhang';
  954. statusDotContent = `<img src="${require(`@/assets/images/icon_${iconName}.png`)}" style="width: 100%; height: 100%; object-fit: contain;" />`;
  955. } else {
  956. statusDotContent = `<span>${data.name.charAt(0)}</span>`;
  957. }
  958. const content = `
  959. <div class="custom-info-card" id="${infoWindowId}">
  960. <div class="close-btn" data-id="${infoWindowId}">✕</div>
  961. <div class="card-header">
  962. <div class="status-dot ${isAbnormal ? 'breathe' : ''}" style="background: ${isAbnormal ? 'transparent' : data.statusColor}; border: ${isAbnormal ? 'none' : ''}">
  963. ${statusDotContent}
  964. </div>
  965. <span class="status-text">${data.statusLabel}</span>
  966. </div>
  967. <div class="card-body">
  968. ${isAbnormal
  969. ? `
  970. <div class="info-line"><span class="label">路口名称:</span><span class="value">${data.road}</span></div>
  971. <div class="info-line"><span class="label">设备状态:</span><span class="value digital">${deviceStatusText}</span></div>
  972. <div class="info-line"><span class="label">报警信息:</span><span class="value">${alarmInfoText}</span></div>
  973. <div class="info-line"><span class="label">发生时间:</span><span class="value digital">${eventTimeText}</span></div>
  974. `
  975. : `
  976. <div class="info-line"><span class="label">路口名称:</span><span class="value">${data.road}</span></div>
  977. <div class="info-line"><span class="label">设备状态:</span><span class="value digital">${deviceStatusText}</span></div>
  978. `
  979. }
  980. </div>
  981. </div>
  982. `;
  983. if (!this.infoWindow) {
  984. this.infoWindow = new this.AMap.InfoWindow({
  985. isCustom: true,
  986. offset: new this.AMap.Pixel(0, -20),
  987. autoMove: false
  988. });
  989. }
  990. this.infoWindow.setContent(content);
  991. this.infoWindow.open(this.map, position);
  992. setTimeout(() => {
  993. if (this.activeInfoWindowId !== infoWindowId) return;
  994. const closeBtn = document.querySelector(`#${infoWindowId} .close-btn`);
  995. if (closeBtn) {
  996. closeBtn.addEventListener('click', () => {
  997. if (this.infoWindow) this.infoWindow.close();
  998. });
  999. }
  1000. const root = document.querySelector(`#${infoWindowId}`);
  1001. if (root) {
  1002. root.addEventListener('mouseenter', () => {
  1003. if (this.activeInfoWindowId !== infoWindowId) return;
  1004. this.isInfoWindowHovered = true;
  1005. this.cancelCloseInfoWindow();
  1006. });
  1007. root.addEventListener('mouseleave', () => {
  1008. if (this.activeInfoWindowId !== infoWindowId) return;
  1009. this.isInfoWindowHovered = false;
  1010. this.scheduleCloseInfoWindow();
  1011. });
  1012. }
  1013. }, 100);
  1014. },
  1015. /**
  1016. * 取消关闭信息窗口
  1017. */
  1018. cancelCloseInfoWindow() {
  1019. if (this.infoCloseTimer) {
  1020. clearTimeout(this.infoCloseTimer);
  1021. this.infoCloseTimer = null;
  1022. }
  1023. },
  1024. /**
  1025. * 安排关闭信息窗口
  1026. */
  1027. scheduleCloseInfoWindow() {
  1028. this.cancelCloseInfoWindow();
  1029. this.infoCloseTimer = setTimeout(() => {
  1030. if (this.isComponentDestroyed) return;
  1031. if (this.isInfoWindowHovered) return;
  1032. if (this.infoWindow) this.infoWindow.close();
  1033. this.activeInfoWindowId = null;
  1034. }, 160);
  1035. },
  1036. /**
  1037. * 获取报警信息文本
  1038. * @param {string} statusName - 状态名称
  1039. * @returns {string} 报警信息
  1040. */
  1041. getAlarmInfoText(statusName) {
  1042. if (statusName === '离线') return '通讯中断设备离线';
  1043. if (statusName === '降级') return '降级定周期控制';
  1044. if (statusName === '故障') return '东向左转信号灯红绿同亮';
  1045. return '设备异常';
  1046. },
  1047. /**
  1048. * 格式化事件时间
  1049. * @param {Date|number|string} input - 时间输入
  1050. * @returns {string} 格式化后的时间
  1051. */
  1052. formatEventTime(input) {
  1053. let d = null;
  1054. if (input instanceof Date) d = input;
  1055. else if (typeof input === 'number') d = new Date(input);
  1056. else if (typeof input === 'string') {
  1057. const t = Date.parse(input);
  1058. if (!Number.isNaN(t)) d = new Date(t);
  1059. }
  1060. if (!d || Number.isNaN(d.getTime())) d = new Date();
  1061. const y = d.getFullYear();
  1062. const m = d.getMonth() + 1;
  1063. const day = d.getDate();
  1064. const hh = String(d.getHours()).padStart(2, '0');
  1065. const mm = String(d.getMinutes()).padStart(2, '0');
  1066. return `${y}.${m}.${day} ${hh}:${mm}`;
  1067. },
  1068. /**
  1069. * 切换所有图例的可见性
  1070. */
  1071. toggleAll() {
  1072. const targetState = !this.isAllSelected;
  1073. if (!this.isMapReady()) return;
  1074. if (targetState) {
  1075. this.activeLegends = this.statusConfig.map(item => item.name);
  1076. Object.values(this.routeGroups).forEach(overlays => {
  1077. if (overlays && overlays.length > 0) this.map.add(overlays);
  1078. });
  1079. } else {
  1080. this.activeLegends = [];
  1081. Object.values(this.routeGroups).forEach(overlays => {
  1082. if (overlays && overlays.length > 0) this.map.remove(overlays);
  1083. });
  1084. if (this.infoWindow) this.infoWindow.close();
  1085. }
  1086. },
  1087. /**
  1088. * 切换指定路线的可见性
  1089. * @param {string} name - 路线名称
  1090. */
  1091. toggleRouteVisible(name) {
  1092. if (!this.isMapReady()) return;
  1093. const overlays = this.routeGroups[name] || [];
  1094. const index = this.activeLegends.indexOf(name);
  1095. if (index > -1) {
  1096. this.activeLegends.splice(index, 1);
  1097. this.map.remove(overlays);
  1098. if (this.infoWindow) this.infoWindow.close();
  1099. } else {
  1100. this.activeLegends.push(name);
  1101. this.map.add(overlays);
  1102. }
  1103. },
  1104. /**
  1105. * 根据位置聚焦地图
  1106. * @param {string|Array<number>} targetPos - 目标位置
  1107. */
  1108. focusByLocation(targetPos) {
  1109. if (!this.isMapReady() || !targetPos) return;
  1110. let pos = targetPos;
  1111. if (typeof targetPos === 'string') {
  1112. pos = targetPos.split(',').map(Number);
  1113. }
  1114. if (!Array.isArray(pos) || pos.length < 2) return;
  1115. const [targetLng, targetLat] = pos;
  1116. let bestMarker = null;
  1117. let minDistanceSq = Infinity;
  1118. Object.values(this.routeGroups).forEach(group => {
  1119. if (!Array.isArray(group)) return;
  1120. group.forEach(item => {
  1121. if (!(item instanceof this.AMap.Marker)) return;
  1122. const markerExt = item.getExtData();
  1123. const markerPos = markerExt.position;
  1124. if (!markerPos) return;
  1125. const dx = markerPos[0] - targetLng;
  1126. const dy = markerPos[1] - targetLat;
  1127. const distSq = dx * dx + dy * dy;
  1128. if (distSq < 0.000001) {
  1129. const isAbnormal = ["离线", "降级", "故障"].includes(markerExt.name);
  1130. const currentIsAbnormal = bestMarker ? ["离线", "降级", "故障"].includes(bestMarker.getExtData().name) : false;
  1131. if (distSq < minDistanceSq || (isAbnormal && !currentIsAbnormal)) {
  1132. minDistanceSq = distSq;
  1133. bestMarker = item;
  1134. }
  1135. }
  1136. });
  1137. });
  1138. if (bestMarker) {
  1139. const finalPos = bestMarker.getPosition();
  1140. this.map.setZoomAndCenter(17, finalPos, false, 500);
  1141. setTimeout(() => {
  1142. if (!this.isComponentDestroyed) this.openLightInfo(bestMarker.getExtData(), finalPos);
  1143. }, 600);
  1144. } else {
  1145. this.map.setZoomAndCenter(17, [targetLng, targetLat], false, 500);
  1146. }
  1147. },
  1148. /**
  1149. * 通过路口编号定位到对应的标记
  1150. * @param {string} id - 路口编号
  1151. * @returns {Array<number>|null} 位置坐标
  1152. */
  1153. focusById(id) {
  1154. if (!this.isMapReady() || !id) return null;
  1155. let bestMarker = null;
  1156. Object.values(this.routeGroups).forEach(group => {
  1157. if (!Array.isArray(group) || bestMarker) return;
  1158. group.forEach(item => {
  1159. if (bestMarker) return;
  1160. if (!(item instanceof this.AMap.Marker)) return;
  1161. const ext = item.getExtData();
  1162. if (ext.id === id || ext['路口编号'] === id) {
  1163. bestMarker = item;
  1164. }
  1165. });
  1166. });
  1167. if (bestMarker) {
  1168. const finalPos = bestMarker.getPosition();
  1169. this.map.setZoomAndCenter(17, finalPos, false, 500);
  1170. return finalPos;
  1171. }
  1172. return null;
  1173. },
  1174. /**
  1175. * 切换图例的可见性
  1176. */
  1177. toggleLegend() {
  1178. this.legendVisible = !this.legendVisible;
  1179. },
  1180. /**
  1181. * 按4:4:4比例提取故障、离线、降级路口信息并存储到localStorage
  1182. */
  1183. storeStatusCoordsToLocalStorage() {
  1184. const alarmTypes = [
  1185. { status: "故障", titles: ["通讯中断", "灯组故障", "相位冲突", "绿冲突"], level: "high", type: "error" },
  1186. { status: "离线", titles: ["信号机离线", "信号机离线", "通讯中断", "检测器异常"], level: "mid", type: "warning" },
  1187. { status: "降级", titles: ["降级黄闪", "降级黄闪", "方案切换异常", "检测器异常"], level: "mid", type: "warning" },
  1188. ];
  1189. const alarmList = [];
  1190. let id = 1;
  1191. alarmTypes.forEach(({ status, titles, level, type }) => {
  1192. (this.statusIntersections[status] || []).slice(0, 4).forEach((item, i) => {
  1193. const lng = item["位置-经度"];
  1194. const lat = item["位置-纬度"];
  1195. const name = item["路口名称"] || "";
  1196. if (!lng || !lat) return;
  1197. alarmList.push({
  1198. id: `A${String(id).padStart(3, "0")}`,
  1199. title: titles[i] || titles[0],
  1200. loc: name,
  1201. level,
  1202. type,
  1203. description: `${name}-${titles[i] || titles[0]}`,
  1204. position: [lng, lat],
  1205. });
  1206. localStorage.setItem(`pos${id}`, `${lng},${lat}`);
  1207. id++;
  1208. });
  1209. });
  1210. localStorage.setItem("alarmListFromMap", JSON.stringify(alarmList));
  1211. console.log('状态坐标及告警数据已存储到localStorage');
  1212. },
  1213. /**
  1214. * 将经纬度转换为像素坐标
  1215. * @param {number} lng - 经度
  1216. * @param {number} lat - 纬度
  1217. * @returns {Object|null} 像素坐标
  1218. */
  1219. lngLatToPixel(lng, lat) {
  1220. if (!this.map) return null;
  1221. return this.map.lngLatToContainer([lng, lat]);
  1222. }
  1223. }
  1224. };
  1225. </script>
  1226. <style scoped>
  1227. .map-wrapper {
  1228. width: 100%;
  1229. height: 100vh;
  1230. position: relative;
  1231. background: #010813;
  1232. }
  1233. .map-container {
  1234. width: 100%;
  1235. height: 100%;
  1236. }
  1237. ::v-deep .amap-logo,
  1238. ::v-deep .amap-copyright,
  1239. ::v-deep .amap-copyright-logo {
  1240. display: none !important;
  1241. }
  1242. ::v-deep .pure-light-node.breathe {
  1243. animation: light-breathe 2s infinite ease-in-out;
  1244. }
  1245. ::v-deep .pure-light-node span {
  1246. display: flex;
  1247. transform: scale(0.75);
  1248. align-items: center;
  1249. }
  1250. ::v-deep .pure-light-node:hover {
  1251. transform: scale(1.4);
  1252. filter: brightness(1.2);
  1253. }
  1254. @keyframes light-breathe {
  1255. 0% {
  1256. transform: scale(0.9);
  1257. opacity: 0.8;
  1258. }
  1259. 50% {
  1260. transform: scale(1.1);
  1261. opacity: 1;
  1262. }
  1263. 100% {
  1264. transform: scale(0.9);
  1265. opacity: 0.8;
  1266. }
  1267. }
  1268. ::v-deep .close-btn {
  1269. position: absolute;
  1270. top: 10px;
  1271. right: 12px;
  1272. color: #8da6c7;
  1273. cursor: pointer;
  1274. font-size: 16px;
  1275. transition: color 0.3s;
  1276. line-height: 1;
  1277. z-index: 10;
  1278. }
  1279. ::v-deep .close-btn:hover {
  1280. color: #ffffff;
  1281. }
  1282. ::v-deep .custom-info-card {
  1283. position: relative;
  1284. background: rgba(10, 15, 24, 0.95);
  1285. border-radius: 10px;
  1286. padding: 12px 16px;
  1287. min-width: 200px;
  1288. border: 1px solid rgba(255, 255, 255, 0.1);
  1289. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
  1290. color: #fff;
  1291. }
  1292. ::v-deep .card-header {
  1293. display: flex;
  1294. align-items: center;
  1295. margin-bottom: 10px;
  1296. }
  1297. ::v-deep .status-dot {
  1298. width: 30px;
  1299. height: 30px;
  1300. border-radius: 50%;
  1301. display: flex;
  1302. justify-content: center;
  1303. align-items: center;
  1304. margin-right: 8px;
  1305. font-size: 14px;
  1306. padding: 0;
  1307. box-sizing: border-box;
  1308. }
  1309. ::v-deep .status-dot.breathe {
  1310. animation: light-breathe 2s infinite ease-in-out;
  1311. }
  1312. ::v-deep .status-dot span {
  1313. transform: scale(0.75);
  1314. }
  1315. ::v-deep .status-dot img {
  1316. width: 100%;
  1317. height: 100%;
  1318. object-fit: contain;
  1319. display: block;
  1320. }
  1321. ::v-deep .status-text {
  1322. font-size: 15px;
  1323. font-weight: bold;
  1324. }
  1325. ::v-deep .info-line {
  1326. display: flex;
  1327. margin-bottom: 6px;
  1328. font-size: 13px;
  1329. align-items: center;
  1330. }
  1331. ::v-deep .label {
  1332. color: #8da6c7;
  1333. white-space: nowrap;
  1334. }
  1335. ::v-deep .value {
  1336. color: #ffffff;
  1337. }
  1338. ::v-deep .digital {
  1339. font-family: 'Consolas', monospace;
  1340. }
  1341. .map-legend {
  1342. position: absolute;
  1343. bottom: 30px;
  1344. right: 40px;
  1345. background: rgba(5, 22, 45, 0.9);
  1346. border: 1px solid #1e4d8e;
  1347. padding: 15px;
  1348. border-radius: 6px;
  1349. z-index: 100;
  1350. transition: all 0.3s ease-in-out;
  1351. opacity: 1;
  1352. max-width: 200px;
  1353. overflow: hidden;
  1354. }
  1355. .map-legend.legend-hidden {
  1356. opacity: 0;
  1357. transform: translateX(calc(100% + 20px));
  1358. pointer-events: none;
  1359. }
  1360. .legend-header {
  1361. display: flex;
  1362. justify-content: space-between;
  1363. align-items: center;
  1364. margin-bottom: 12px;
  1365. border-bottom: 1px solid #1e4d8e;
  1366. padding-bottom: 8px;
  1367. }
  1368. .legend-close-btn {
  1369. color: #8da6c7;
  1370. cursor: pointer;
  1371. font-size: 16px;
  1372. transition: color 0.3s;
  1373. line-height: 1;
  1374. }
  1375. .legend-close-btn:hover {
  1376. color: #ffffff;
  1377. }
  1378. .legend-show-btn {
  1379. position: absolute;
  1380. bottom: 30px;
  1381. right: 20px;
  1382. background: rgba(5, 22, 45, 0.9);
  1383. border: 1px solid #1e4d8e;
  1384. width: 40px;
  1385. height: 40px;
  1386. border-radius: 6px 0 0 6px;
  1387. display: flex;
  1388. justify-content: center;
  1389. align-items: center;
  1390. cursor: pointer;
  1391. z-index: 99;
  1392. transition: all 0.3s ease-in-out;
  1393. }
  1394. .legend-show-btn:hover {
  1395. background: rgba(10, 30, 60, 0.9);
  1396. border-color: #3a75c4;
  1397. }
  1398. .legend-show-icon {
  1399. color: #8da6c7;
  1400. font-size: 18px;
  1401. transition: color 0.3s;
  1402. }
  1403. .legend-show-btn:hover .legend-show-icon {
  1404. color: #ffffff;
  1405. }
  1406. .legend-title {
  1407. color: #fff;
  1408. font-size: 14px;
  1409. }
  1410. .legend-item {
  1411. display: flex;
  1412. align-items: center;
  1413. margin-bottom: 10px;
  1414. cursor: pointer;
  1415. transition: 0.3s;
  1416. }
  1417. .legend-item.is-inactive {
  1418. opacity: 0.2;
  1419. filter: grayscale(1);
  1420. }
  1421. .legend-dot {
  1422. margin-right: 10px;
  1423. display: flex;
  1424. justify-content: center;
  1425. align-items: center;
  1426. }
  1427. .legend-dot:not(.is-status-wrapper) {
  1428. width: 20px;
  1429. height: 20px;
  1430. border-radius: 50%;
  1431. }
  1432. .legend-dot span {
  1433. display: inline-block;
  1434. width: 20px;
  1435. height: 20px;
  1436. font-size: 12px;
  1437. color: #FFF;
  1438. text-align: center;
  1439. transform: scale(0.75);
  1440. line-height: 20px;
  1441. }
  1442. .legend-dot.special-route {
  1443. position: relative;
  1444. overflow: visible !important;
  1445. z-index: 1;
  1446. border: none !important;
  1447. border-radius: 50%;
  1448. }
  1449. .legend-dot.special-route span {
  1450. position: relative;
  1451. z-index: 3;
  1452. font-weight: bold;
  1453. }
  1454. .legend-dot.special-route::after {
  1455. content: "";
  1456. position: absolute;
  1457. top: 50%;
  1458. left: 50%;
  1459. width: 150%;
  1460. height: 3px;
  1461. background-color: inherit;
  1462. opacity: 0.8;
  1463. transform: translate(-50%, -50%) rotate(-45deg);
  1464. pointer-events: none;
  1465. z-index: 0;
  1466. }
  1467. .legend-dot.special-route::before {
  1468. content: "";
  1469. position: absolute;
  1470. top: 0;
  1471. left: 0;
  1472. width: 100%;
  1473. height: 100%;
  1474. border-radius: 50%;
  1475. z-index: 2;
  1476. background-color: inherit;
  1477. opacity: 1;
  1478. }
  1479. .legend-label {
  1480. flex: 1;
  1481. color: #d0d9e2;
  1482. font-size: 13px;
  1483. }
  1484. .legend-status {
  1485. font-size: 11px;
  1486. color: #5b7da8;
  1487. }
  1488. .all-select {
  1489. border-bottom: 1px solid rgba(255, 255, 255, 0.2);
  1490. padding-bottom: 12px;
  1491. margin-bottom: 12px !important;
  1492. }
  1493. .all-select .legend-dot {
  1494. border-radius: 2px;
  1495. transition: all 0.3s;
  1496. width: 10px;
  1497. height: 10px;
  1498. }
  1499. .legend-dot.is-status-wrapper {
  1500. width: 28px;
  1501. height: 28px;
  1502. background-color: transparent !important;
  1503. box-shadow: none !important;
  1504. border: none !important;
  1505. margin-right: 0;
  1506. transform: translateX(-15%);
  1507. }
  1508. .status-icon {
  1509. width: 100%;
  1510. height: 100%;
  1511. object-fit: contain;
  1512. display: block;
  1513. }
  1514. ::v-deep .pure-light-node {
  1515. width: 16px;
  1516. height: 16px;
  1517. border-radius: 50%;
  1518. border: 2px solid rgba(255, 255, 255, 0.8);
  1519. cursor: pointer;
  1520. transition: all 0.3s;
  1521. display: flex;
  1522. justify-content: center;
  1523. align-items: center;
  1524. color: #fff;
  1525. pointer-events: auto;
  1526. }
  1527. ::v-deep .pure-light-node.route-node {
  1528. position: relative;
  1529. overflow: visible;
  1530. }
  1531. ::v-deep .pure-light-node.route-node::after {
  1532. content: "";
  1533. position: absolute;
  1534. top: 50%;
  1535. left: 50%;
  1536. width: 150%;
  1537. height: 3px;
  1538. background-color: inherit;
  1539. opacity: 0.8;
  1540. transform: translate(-50%, -50%) rotate(-45deg);
  1541. pointer-events: none;
  1542. }
  1543. ::v-deep .pure-light-node.route-node span {
  1544. position: relative;
  1545. z-index: 1;
  1546. }
  1547. ::v-deep .pure-light-node:not(.abnormal-node) {
  1548. border: 1px solid rgba(255, 255, 255, 0.4);
  1549. }
  1550. ::v-deep .duty-progress-node {
  1551. animation: duty-pulse 1.6s infinite ease-in-out;
  1552. }
  1553. @keyframes duty-pulse {
  1554. 0% { box-shadow: 0 0 0 3px rgba(188, 48, 29, 0.5); }
  1555. 50% { box-shadow: 0 0 0 9px rgba(188, 48, 29, 0.08); }
  1556. 100% { box-shadow: 0 0 0 3px rgba(188, 48, 29, 0.5); }
  1557. }
  1558. </style>