TrafficTimeSpace.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. <template>
  2. <div ref="chartRef" class="traffic-timespace-chart"></div>
  3. </template>
  4. <script>
  5. import * as echarts from 'echarts';
  6. import echartsResize, { px2echarts } from '@/mixins/echartsResize.js';
  7. export default {
  8. name: 'TrafficTimeSpace',
  9. mixins: [echartsResize],
  10. props: {
  11. // 【终极修复】:使用 0, 50, 0, 0 神级协调相位差
  12. roadSegments: {
  13. type: Array,
  14. required: true,
  15. default: () => [
  16. { name: '交叉口A', distanceNext: 450, offset: 0 },
  17. { name: '交叉口B', distanceNext: 669, offset: 50 },
  18. { name: '交叉口C', distanceNext: 1050, offset: 0 },
  19. { name: '交叉口D', distanceNext: 0, offset: 0 }
  20. ]
  21. },
  22. speedKmh: { type: Number, default: 38.9 }, // 设计车速
  23. cycle: { type: Number, default: 100 }, // 红绿灯周期
  24. greenDuration: { type: Number, default: 40 }, // 绿灯时长
  25. bandwidth: { type: Number, default: 31.5 }, // 波带宽(秒)
  26. viewWindow: { type: Number, default: 420 },
  27. autoScroll: { type: Boolean, default: false },
  28. scrollSpeed: { type: Number, default: 0.2 },
  29. upWaveColor: { type: String, default: 'rgba(46, 204, 113, 0.45)' },
  30. downWaveColor: { type: String, default: 'rgba(52, 152, 219, 0.45)' },
  31. showYSplitLine: { type: Boolean, default: false },
  32. showPhaseOffset: { type: Boolean, default: true },
  33. showScanLine: { type: Boolean, default: true },
  34. scanLineColor: { type: String, default: 'rgba(64, 158, 255, 0.9)' },
  35. scanLineStart: { type: Number, default: 0 }
  36. },
  37. data() {
  38. return {
  39. $_chart: null,
  40. scrollTimer: null,
  41. scanLineTimer: null,
  42. currentViewMinX: 0,
  43. currentScanX: 0,
  44. };
  45. },
  46. computed: {
  47. barWidth() {
  48. const scale = typeof window !== 'undefined' ? Math.max(1, window.innerWidth / 1920) : 1;
  49. return Math.round(8 * scale);
  50. },
  51. gap() {
  52. const scale = typeof window !== 'undefined' ? Math.max(1, window.innerWidth / 1920) : 1;
  53. return Math.round(2 * scale);
  54. },
  55. intersections() {
  56. let currentX = 0;
  57. return this.roadSegments.map((seg, i) => {
  58. const intersection = {
  59. id: `I${i}`,
  60. name: seg.name,
  61. distanceNext: seg.distanceNext,
  62. x: currentX,
  63. offsetText: `${seg.offset}秒`,
  64. offset: seg.offset
  65. };
  66. currentX += seg.distanceNext;
  67. return intersection;
  68. });
  69. },
  70. maxX() {
  71. const last = this.intersections[this.intersections.length - 1];
  72. return last.x + 80;
  73. },
  74. yAxisMin() {
  75. // 留出底部 padding,避免首个路口(distance=0)向下延伸的柱子被 clip 裁掉
  76. return -80;
  77. },
  78. maxDataTime() {
  79. return this.viewWindow * 3;
  80. }
  81. },
  82. mounted() {
  83. this.$nextTick(() => {
  84. this.initChart();
  85. if (this.autoScroll) this.startScroll();
  86. if (this.showScanLine) this.startScanLine();
  87. });
  88. },
  89. beforeDestroy() {
  90. this.stopScroll();
  91. this.stopScanLine();
  92. if (this.$_chart) this.$_chart.dispose();
  93. },
  94. watch: {
  95. roadSegments: { handler() { this.updateChart(); }, deep: true },
  96. speedKmh() { this.updateChart(); },
  97. autoScroll(val) { val ? this.startScroll() : this.stopScroll(); }
  98. },
  99. methods: {
  100. // 根据大屏宽度缩放字号/像素常量,避免大屏下文字过小
  101. fs(px) {
  102. const scaled = typeof px2echarts === 'function' ? px2echarts(px) : px;
  103. return Math.max(px, scaled);
  104. },
  105. initChart() {
  106. if (!this.$refs.chartRef) return;
  107. this.$_chart = echarts.init(this.$refs.chartRef);
  108. this.updateChart();
  109. },
  110. generateBarData() {
  111. const data = [];
  112. const colors = { red: '#e74c3c', green: '#2ecc71', blue: '#3498db' };
  113. this.intersections.forEach(intersection => {
  114. for (let k = -2; k <= Math.ceil(this.maxDataTime / this.cycle) + 2; k++) {
  115. const pStart = intersection.offset + k * this.cycle;
  116. const pEnd = pStart + this.greenDuration;
  117. const rStart = pEnd;
  118. const rEnd = pStart + this.cycle;
  119. data.push([intersection.x, pStart, pEnd, colors.green, -1]);
  120. data.push([intersection.x, pStart, pEnd, colors.blue, 1]);
  121. data.push([intersection.x, rStart, rEnd, colors.red, -1]);
  122. data.push([intersection.x, rStart, rEnd, colors.red, 1]);
  123. }
  124. });
  125. return data;
  126. },
  127. getWaveData() {
  128. const speedMs = this.speedKmh / 3.6; // 设计车速换算为 m/s
  129. const startX = this.intersections[0].x;
  130. const endX = this.intersections[this.intersections.length - 1].x;
  131. const travelTime = (endX - startX) / speedMs;
  132. // 正向绿波 (A -> D):gStartY 秒起步
  133. const gStartY = 0;
  134. const greenBottomLine = [
  135. [startX, gStartY],
  136. [endX, gStartY + travelTime]
  137. ];
  138. const greenTopLine = [
  139. [startX, gStartY + this.bandwidth],
  140. [endX, gStartY + travelTime + this.bandwidth]
  141. ];
  142. const greenCoords = [...greenBottomLine, ...[...greenTopLine].reverse()];
  143. // 反向蓝波 (D -> A):bStartYAtD 秒起步
  144. const bStartYAtD = 100;
  145. const blueBottomLine = [
  146. [endX, bStartYAtD],
  147. [startX, bStartYAtD + travelTime]
  148. ];
  149. const blueTopLine = [
  150. [endX, bStartYAtD + this.bandwidth],
  151. [startX, bStartYAtD + travelTime + this.bandwidth]
  152. ];
  153. const blueCoords = [...blueBottomLine, ...[...blueTopLine].reverse()];
  154. return [
  155. {
  156. coords: greenCoords,
  157. bottomLine: greenBottomLine,
  158. topLine: greenTopLine,
  159. color: this.upWaveColor,
  160. lineCol: '#2ecc71',
  161. isBlue: false
  162. },
  163. {
  164. coords: blueCoords,
  165. bottomLine: blueBottomLine,
  166. topLine: blueTopLine,
  167. color: this.downWaveColor,
  168. lineCol: '#3498db',
  169. isBlue: true
  170. }
  171. ];
  172. },
  173. updateChart() {
  174. if (!this.$_chart) return;
  175. // x 轴刻度仅对齐首路口(A)的红块首尾
  176. const gcd = (a, b) => (b === 0 ? Math.abs(a) : gcd(b, a % b));
  177. const firstOffset = this.intersections[0] ? this.intersections[0].offset : 0;
  178. const redStartMod = ((firstOffset + this.greenDuration) % this.cycle + this.cycle) % this.cycle; // 红块起点
  179. const redEndMod = ((firstOffset) % this.cycle + this.cycle) % this.cycle; // 红块终点 = 下周期起点
  180. // interval 需同时整除 redStartMod 与 redEndMod 的间距,保证 label 落点
  181. const tickInterval = gcd(gcd(this.greenDuration, this.cycle - this.greenDuration), firstOffset || this.cycle);
  182. const option = {
  183. backgroundColor: 'transparent',
  184. animation: false,
  185. tooltip: { show: false },
  186. grid: { left: this.fs(140), right: this.fs(40), top: this.fs(40), bottom: this.fs(50) },
  187. xAxis: {
  188. type: 'value', min: this.currentViewMinX, max: this.currentViewMinX + this.viewWindow,
  189. interval: tickInterval, name: '时间 (秒)',
  190. nameLocation: 'end',
  191. nameTextStyle: { color: '#a0aabf', padding: [0, 0, 0, 0], fontSize: this.fs(12) },
  192. axisLine: { show: true, onZero: false, lineStyle: { color: 'rgba(255,255,255,0.5)' } },
  193. axisTick: {
  194. show: true,
  195. lineStyle: { color: 'rgba(255,255,255,0.5)' },
  196. length: this.fs(5)
  197. },
  198. axisLabel: {
  199. color: '#a0aabf',
  200. fontSize: this.fs(10),
  201. formatter: (value) => {
  202. const mod = ((value % this.cycle) + this.cycle) % this.cycle;
  203. return (mod === redStartMod || mod === redEndMod) ? value : '';
  204. }
  205. },
  206. splitLine: { show: this.showYSplitLine, lineStyle: { type: 'dashed', color: 'rgba(255, 255, 255, 0.08)' } }
  207. },
  208. yAxis: {
  209. type: 'value', min: this.yAxisMin, max: this.maxX,
  210. axisLine: { show: true, onZero: false, lineStyle: { color: 'rgba(255,255,255,0.15)' } },
  211. axisTick: { show: false },
  212. axisLabel: { show: false },
  213. splitLine: { show: false }
  214. },
  215. series: [
  216. {
  217. id: 'waveSeries',
  218. type: 'custom',
  219. clip: true,
  220. data: this.getWaveData(),
  221. renderItem: (params, api) => {
  222. const item = this.getWaveData()[params.dataIndex];
  223. const pixelOffsetY = item.isBlue ? (this.barWidth * 1.5 + this.gap) : (this.barWidth / 2);
  224. // coords 原始格式 [距离, 时间],互换轴后映射为 api.coord([时间, 距离])
  225. const mapPt = c => {
  226. const pt = api.coord([c[1], c[0]]);
  227. return [pt[0], pt[1] + pixelOffsetY];
  228. };
  229. const mappedCoords = item.coords.map(mapPt);
  230. const mappedBottom = item.bottomLine.map(mapPt);
  231. const mappedTop = item.topLine.map(mapPt);
  232. const segIdx = 0;
  233. const pB1 = mappedBottom[segIdx];
  234. const pB2 = mappedBottom[segIdx + 1];
  235. const pT1 = mappedTop[segIdx];
  236. const pT2 = mappedTop[segIdx + 1];
  237. // 波带角度统一用底线方向(atan2),保证文字沿着波带方向读
  238. const angle = Math.atan2(pB2[1] - pB1[1], pB2[0] - pB1[0]);
  239. // 绿波速度文字沿波带方向顺时针 90°;蓝波沿波带方向逆时针 90°
  240. const speedAngle = item.isBlue ? angle - Math.PI / 2 : angle + Math.PI / 2;
  241. const percent = 0.15;
  242. const midBottomX = pB1[0] + (pB2[0] - pB1[0]) * percent;
  243. const midBottomY = pB1[1] + (pB2[1] - pB1[1]) * percent;
  244. const midTopX = pT1[0] + (pT2[0] - pT1[0]) * percent;
  245. const midTopY = pT1[1] + (pT2[1] - pT1[1]) * percent;
  246. // 速度文字沿波带法线向外偏移;与虚线保持同侧(绿波→顶线侧,蓝波→底线侧)
  247. const sBDx = pB2[0] - pB1[0];
  248. const sBDy = pB2[1] - pB1[1];
  249. const sBLen = Math.sqrt(sBDx * sBDx + sBDy * sBDy) || 1;
  250. const sPerpX = sBDy / sBLen;
  251. const sPerpY = -sBDx / sBLen;
  252. const sDot = (pT1[0] - pB1[0]) * sPerpX + (pT1[1] - pB1[1]) * sPerpY;
  253. const speedOff = this.fs(14);
  254. let speedX, speedY;
  255. if (item.isBlue) {
  256. // 蓝波:底线外侧
  257. const sSign = sDot > 0 ? -1 : 1;
  258. speedX = midBottomX + sSign * sPerpX * speedOff;
  259. speedY = midBottomY + sSign * sPerpY * speedOff;
  260. } else {
  261. // 绿波:顶线外侧(虚线这边)
  262. const sSign = sDot > 0 ? 1 : -1;
  263. speedX = midTopX + sSign * sPerpX * speedOff;
  264. speedY = midTopY + sSign * sPerpY * speedOff;
  265. }
  266. return {
  267. type: 'group',
  268. children: [
  269. { type: 'polygon', shape: { points: mappedCoords }, style: { fill: item.color } },
  270. ...(() => {
  271. // 虚线沿垂直于波带方向向外偏移:绿波走顶线外侧,蓝波走底线外侧(互相镜像)
  272. const lineGap = this.fs(4);
  273. const bDx = mappedBottom[1][0] - mappedBottom[0][0];
  274. const bDy = mappedBottom[1][1] - mappedBottom[0][1];
  275. const bLen = Math.sqrt(bDx * bDx + bDy * bDy) || 1;
  276. const perpX = bDy / bLen;
  277. const perpY = -bDx / bLen;
  278. const midBx = (mappedBottom[0][0] + mappedBottom[1][0]) / 2;
  279. const midBy = (mappedBottom[0][1] + mappedBottom[1][1]) / 2;
  280. const midTx = (mappedTop[0][0] + mappedTop[1][0]) / 2;
  281. const midTy = (mappedTop[0][1] + mappedTop[1][1]) / 2;
  282. const dot = (midTx - midBx) * perpX + (midTy - midBy) * perpY;
  283. let points;
  284. if (item.isBlue) {
  285. // 蓝波:底线外侧(远离顶线)
  286. const sign = dot > 0 ? -1 : 1;
  287. points = mappedBottom.map(p => [p[0] + sign * perpX * lineGap, p[1] + sign * perpY * lineGap]);
  288. } else {
  289. // 绿波:顶线外侧(远离底线)
  290. const sign = dot > 0 ? 1 : -1;
  291. points = mappedTop.map(p => [p[0] + sign * perpX * lineGap, p[1] + sign * perpY * lineGap]);
  292. }
  293. return [
  294. { type: 'polyline', shape: { points }, style: { stroke: item.lineCol, lineDash: [4, 4], lineWidth: 1 } }
  295. ];
  296. })(),
  297. { type: 'text', x: speedX, y: speedY, rotation: speedAngle, style: { text: `${this.speedKmh}km/h`, fill: '#fff', fontSize: this.fs(11), textAlign: 'center' } },
  298. ...(() => {
  299. const cxBw = (midBottomX + midTopX) / 2;
  300. const cyBw = (midBottomY + midTopY) / 2;
  301. const dxBw = midTopX - midBottomX;
  302. const dyBw = midTopY - midBottomY;
  303. const lenBw = Math.sqrt(dxBw * dxBw + dyBw * dyBw) || 1;
  304. const uxBw = dxBw / lenBw;
  305. const uyBw = dyBw / lenBw;
  306. const gapBw = this.fs(10);
  307. return [
  308. { type: 'line', shape: { x1: midBottomX, y1: midBottomY, x2: cxBw - uxBw * gapBw, y2: cyBw - uyBw * gapBw }, style: { stroke: 'rgba(255, 255, 255, 0.4)' } },
  309. { type: 'text', x: cxBw, y: cyBw, rotation: angle, style: { text: `${this.bandwidth}s`, fill: '#fff', fontSize: this.fs(11), textAlign: 'center', textVerticalAlign: 'middle' } },
  310. { type: 'line', shape: { x1: cxBw + uxBw * gapBw, y1: cyBw + uyBw * gapBw, x2: midTopX, y2: midTopY }, style: { stroke: 'rgba(255, 255, 255, 0.4)' } }
  311. ];
  312. })()
  313. ]
  314. };
  315. }
  316. },
  317. {
  318. id: 'lightSeries',
  319. type: 'custom',
  320. clip: true,
  321. data: this.generateBarData(),
  322. renderItem: (params, api) => {
  323. const distance = api.value(0);
  324. const tStart = api.value(1);
  325. const tEnd = api.value(2);
  326. const color = api.value(3);
  327. const direction = api.value(4);
  328. const startP = api.coord([tStart, distance]);
  329. const endP = api.coord([tEnd, distance]);
  330. const rectY = direction === -1 ? startP[1] : (startP[1] + this.barWidth + this.gap);
  331. return {
  332. type: 'rect',
  333. shape: { x: startP[0], y: rectY, width: endP[0] - startP[0], height: this.barWidth },
  334. style: { fill: color }
  335. };
  336. }
  337. },
  338. {
  339. id: 'axisLabels',
  340. type: 'custom',
  341. clip: false,
  342. data: this.intersections,
  343. renderItem: (params, api) => {
  344. const intersection = this.intersections[params.dataIndex];
  345. const point = api.coord([this.currentViewMinX, intersection.x]);
  346. const centerYPx = point[1] + this.barWidth + this.gap / 2;
  347. const labelX = point[0] - this.fs(12);
  348. const nameFs = this.fs(12);
  349. const subFs = this.fs(11);
  350. // 名称较长时按 "-" 拆成两行显示
  351. const dashIdx = intersection.name.indexOf('-');
  352. const nameLine1 = dashIdx >= 0 ? intersection.name.slice(0, dashIdx) : intersection.name;
  353. const nameLine2 = dashIdx >= 0 ? intersection.name.slice(dashIdx + 1) : '';
  354. const children = [];
  355. if (nameLine2) {
  356. children.push({ type: 'text', x: labelX, y: centerYPx - this.fs(15), style: { text: nameLine1, fill: '#fff', textAlign: 'right', fontSize: nameFs } });
  357. children.push({ type: 'text', x: labelX, y: centerYPx - this.fs(1), style: { text: nameLine2, fill: '#fff', textAlign: 'right', fontSize: nameFs } });
  358. } else {
  359. children.push({ type: 'text', x: labelX, y: centerYPx - this.fs(7), style: { text: nameLine1, fill: '#fff', textAlign: 'right', fontSize: nameFs } });
  360. }
  361. if (this.showPhaseOffset) {
  362. const offYPx = nameLine2 ? centerYPx + this.fs(14) : centerYPx + this.fs(5);
  363. children.push({ type: 'text', x: labelX, y: offYPx, style: { text: `相位差: ${intersection.offsetText}`, fill: '#a0aabf', textAlign: 'right', fontSize: subFs } });
  364. }
  365. if (intersection.distanceNext) {
  366. const nextP = api.coord([this.currentViewMinX, intersection.x + intersection.distanceNext]);
  367. const nextCenterYPx = nextP[1] + this.barWidth + this.gap / 2;
  368. const midYPx = (centerYPx + nextCenterYPx) / 2;
  369. // 非反转 yAxis:距离越大像素 y 越小,nextCenterYPx < centerYPx
  370. const lineX = labelX - this.fs(10);
  371. children.push({ type: 'line', shape: { x1: lineX, y1: centerYPx - this.fs(36), x2: lineX, y2: nextCenterYPx + this.fs(22) }, style: { stroke: 'rgba(255,255,255,0.1)', lineDash: [2, 2] } });
  372. children.push({ type: 'text', x: lineX, y: midYPx, style: { text: `${intersection.distanceNext}m`, fill: '#a0aabf', textAlign: 'center', fontSize: subFs } });
  373. }
  374. return { type: 'group', children };
  375. }
  376. },
  377. ...(this.showScanLine ? [{
  378. id: 'scanLineSeries',
  379. type: 'custom',
  380. clip: true,
  381. data: [[this.currentScanX]],
  382. renderItem: (params, api) => {
  383. const xTime = api.value(0);
  384. const top = api.coord([xTime, this.yAxisMin]);
  385. const bottom = api.coord([xTime, this.maxX]);
  386. return {
  387. type: 'line',
  388. shape: { x1: top[0], y1: top[1], x2: bottom[0], y2: bottom[1] },
  389. style: { stroke: this.scanLineColor, lineWidth: this.fs(4) }
  390. };
  391. },
  392. z: 10
  393. }] : [])
  394. ]
  395. };
  396. this.$_chart.setOption(option, { replaceMerge: ['series'] });
  397. },
  398. startScanLine() {
  399. this.stopScanLine();
  400. this.currentScanX = this.viewWindow * Math.min(1, Math.max(0, this.scanLineStart));
  401. const step = this.viewWindow / 100;
  402. this.scanLineTimer = setInterval(() => {
  403. this.currentScanX += step;
  404. if (this.currentScanX > this.currentViewMinX + this.viewWindow) {
  405. this.currentScanX = this.currentViewMinX;
  406. }
  407. if (this.$_chart) {
  408. this.$_chart.setOption({
  409. series: [{ id: 'scanLineSeries', data: [[this.currentScanX]] }]
  410. });
  411. }
  412. }, 1000);
  413. },
  414. stopScanLine() {
  415. if (this.scanLineTimer) {
  416. clearInterval(this.scanLineTimer);
  417. this.scanLineTimer = null;
  418. }
  419. },
  420. startScroll() {
  421. this.stopScroll();
  422. this.scrollTimer = setInterval(() => {
  423. this.currentViewMinX += this.scrollSpeed;
  424. if (this.currentViewMinX > this.maxDataTime - this.viewWindow) {
  425. this.currentViewMinX = 0;
  426. }
  427. if (this.$_chart) {
  428. this.$_chart.setOption({
  429. xAxis: { min: this.currentViewMinX, max: this.currentViewMinX + this.viewWindow }
  430. });
  431. }
  432. }, 16);
  433. },
  434. stopScroll() {
  435. if (this.scrollTimer) {
  436. clearInterval(this.scrollTimer);
  437. this.scrollTimer = null;
  438. }
  439. }
  440. }
  441. };
  442. </script>
  443. <style scoped>
  444. .traffic-timespace-chart {
  445. width: 100%;
  446. height: 100%;
  447. min-height: 0;
  448. flex: 1;
  449. }
  450. </style>