SignalTimingChart.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. <template>
  2. <div ref="chartRef" class="chart-container"></div>
  3. </template>
  4. <script>
  5. import * as echarts from 'echarts';
  6. import echartsResize from '@/mixins/echartsResize.js';
  7. // 全局心跳定时器:所有 autoScan 实例共享同一个 setInterval
  8. // 各实例基于 Date.now() 对自身 cycleLength 取模计算位置,天然同步
  9. let _globalTimer = null;
  10. let _globalListeners = new Set();
  11. function joinGlobalTimer(listener) {
  12. _globalListeners.add(listener);
  13. if (!_globalTimer) {
  14. _globalTimer = setInterval(() => {
  15. const nowSec = Math.floor(Date.now() / 1000);
  16. _globalListeners.forEach(fn => fn(nowSec));
  17. }, 1000);
  18. }
  19. // 立即触发一次,避免 mounted 到首次 tick 之间的空白
  20. listener(Math.floor(Date.now() / 1000));
  21. }
  22. function leaveGlobalTimer(listener) {
  23. _globalListeners.delete(listener);
  24. if (_globalListeners.size === 0 && _globalTimer) {
  25. clearInterval(_globalTimer);
  26. _globalTimer = null;
  27. }
  28. }
  29. const COLORS = {
  30. GREEN_LIGHT: '#8dc453', GREEN_DARK: '#73a542', YELLOW: '#fbd249', RED: '#ff7575', STRIPE_GREEN: '#a3d76e',
  31. TEXT_DARK: '#1e2638', AXIS_LINE: '#758599', DIVIDER_LINE: '#111827', MARK_BLUE: '#00E5FF', TEXT_LIGHT: '#d1d5db'
  32. };
  33. // 绘制条纹图案用于绿闪/预警
  34. const stripeCanvas = document.createElement('canvas');
  35. stripeCanvas.width = 4; stripeCanvas.height = 20;
  36. const ctx = stripeCanvas.getContext('2d');
  37. ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, 4, 20);
  38. ctx.fillStyle = COLORS.STRIPE_GREEN; ctx.fillRect(0, 0, 2, 20);
  39. const stripePattern = { image: stripeCanvas, repeat: 'repeat' };
  40. const IMAGE_MAP = {
  41. 'STRAIGHT_DOWN': require('@/assets/images/icon_straight_down.png'),
  42. 'TURN_DOWN_LEFT': require('@/assets/images/icon_turn_down_left.png'),
  43. 'TURN_DOWN_LEFT_UTURN': require('@/assets/images/icon_turn_down_left_uturn.png'),
  44. 'STRAIGHT_UP': require('@/assets/images/icon_straight_up.png'),
  45. 'TURN_UP_LEFT': require('@/assets/images/icon_turn_up_left.png'),
  46. 'TURN_UP_LEFT_UTURN': require('@/assets/images/icon_turn_up_left_uturn.png'),
  47. 'STRAIGHT_LEFT': require('@/assets/images/icon_straight_left.png'),
  48. 'TURN_LEFT_DOWN': require('@/assets/images/icon_turn_left_down.png'),
  49. 'TURN_LEFT_DOWN_UTURN': require('@/assets/images/icon_turn_left_down_uturn.png'),
  50. 'STRAIGHT_RIGHT': require('@/assets/images/icon_straight_right.png'),
  51. 'TURN_RIGHT_UP': require('@/assets/images/icon_turn_right_up.png'),
  52. 'TURN_RIGHT_UP_UTURN': require('@/assets/images/icon_turn_right_up_uturn.png' )
  53. };
  54. // ==========================================
  55. // 核心逻辑:基于真实物理空间的对齐与自定义偏移/尺寸配置
  56. // pos: 位置(LT/RT/LB/RB), padX/padY: 基础像素偏移, baseW/baseH: 基础原始宽高
  57. // ==========================================
  58. const POS_MAP = {
  59. // 1. 上方驶入 -> 靠左上角 (LT)
  60. 'STRAIGHT_DOWN': { pos: 'LT', padX: 10, padY: 0, baseW: 7, baseH: 20.67 },
  61. 'TURN_DOWN_LEFT': { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },
  62. 'TURN_DOWN_LEFT_UTURN': { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
  63. // 2. 下方驶入 -> 靠右下角 (RB)
  64. 'STRAIGHT_UP': { pos: 'RB', padX: 10, padY: 0, baseW: 7, baseH: 20.67 },
  65. 'TURN_UP_LEFT': { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },
  66. 'TURN_UP_LEFT_UTURN': { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
  67. // 3. 右侧驶入 -> 靠右上角 (RT)
  68. 'STRAIGHT_LEFT': { pos: 'RT', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },
  69. 'TURN_LEFT_DOWN': { pos: 'RT', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },
  70. 'TURN_LEFT_DOWN_UTURN': { pos: 'RT', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
  71. // 4. 左侧驶入 -> 靠左下角 (LB)
  72. 'STRAIGHT_RIGHT': { pos: 'LB', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },
  73. 'TURN_RIGHT_UP': { pos: 'LB', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },
  74. 'TURN_RIGHT_UP_UTURN': { pos: 'LB', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
  75. };
  76. export default {
  77. name: 'SignalTimingChart',
  78. mixins: [echartsResize],
  79. props: {
  80. cycleLength: { type: Number, default: 140 },
  81. currentTime: { type: Number, default: 0 },
  82. phaseData: { type: Array, default: () => [] },
  83. showAxis: { type: Boolean, default: true },
  84. showScanLine: { type: Boolean, default: true },
  85. showScanLineLabel: { type: Boolean, default: true },
  86. autoScan: { type: Boolean, default: false }
  87. },
  88. data() {
  89. return { scaleFactor: 1, internalTime: 0 };
  90. },
  91. computed: {
  92. activeTime() {
  93. return this.autoScan ? this.internalTime : this.currentTime;
  94. }
  95. },
  96. mounted() {
  97. this.internalTime = this.currentTime;
  98. this.initChart();
  99. if (this.autoScan) this.startAutoScan();
  100. },
  101. beforeDestroy() {
  102. this.stopAutoScan();
  103. },
  104. watch: {
  105. currentTime(val) {
  106. if (!this.autoScan) {
  107. if (this.$_chart) this.updateScanLine();
  108. }
  109. },
  110. autoScan(val) {
  111. if (val) { this.startAutoScan(); } else { this.stopAutoScan(); }
  112. },
  113. showScanLine(val) {
  114. this.updateChart();
  115. if (val && this.autoScan) { this.startAutoScan(); }
  116. },
  117. phaseData: { deep: true, handler(newVal) { if (this.$_chart && newVal.length > 0) this.updateChart(); } },
  118. showAxis() { this.updateChart(); },
  119. showScanLineLabel() { this.updateChart(); }
  120. },
  121. methods: {
  122. updateScale() {
  123. const el = this.$el;
  124. if (!el) return;
  125. this.scaleFactor = Math.max(0.5, el.clientWidth / 600);
  126. },
  127. startAutoScan() {
  128. this.stopAutoScan();
  129. // 全局心跳 + 绝对时间取模:相同 cycleLength 的实例扫描线天然同步
  130. this._scanListener = (nowSec) => {
  131. const realMax = this.getMaxTime();
  132. this.internalTime = nowSec % realMax;
  133. if (this.$_chart) this.updateScanLine();
  134. this.$emit('scan-tick', this.internalTime);
  135. };
  136. joinGlobalTimer(this._scanListener);
  137. },
  138. stopAutoScan() {
  139. if (this._scanListener) { leaveGlobalTimer(this._scanListener); this._scanListener = null; }
  140. },
  141. initChart() {
  142. const chartDom = this.$refs.chartRef;
  143. if (!chartDom) return;
  144. this.updateScale();
  145. this.$_chart = echarts.init(chartDom);
  146. if (this.phaseData.length > 0) this.updateChart();
  147. },
  148. updateChart() {
  149. if (!this.$_chart) return;
  150. this.updateScale();
  151. this.$_chart.setOption(this.getChartOption(), true);
  152. },
  153. updateScanLine() {
  154. if (!this.$_chart) return;
  155. this.updateScale();
  156. const s = this.scaleFactor;
  157. const realMaxTime = this.getMaxTime();
  158. this.$_chart.setOption({
  159. series: [{
  160. markLine: !this.showScanLine ? false : {
  161. symbol: ['none', 'none'],
  162. silent: true,
  163. animation: false,
  164. label: {
  165. show: this.showScanLineLabel,
  166. position: 'start',
  167. formatter: `${Math.round(this.activeTime)}/${realMaxTime}`,
  168. color: '#fff', backgroundColor: COLORS.MARK_BLUE,
  169. padding: [Math.round(4 * s), Math.round(8 * s)],
  170. borderRadius: 2, fontSize: Math.max(10, Math.round(10 * s)),
  171. offset: [0, Math.round(1 * s)]
  172. },
  173. lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(2, Math.round(5 * s)), z: 100 },
  174. data: [{ xAxis: this.activeTime }]
  175. }
  176. }]
  177. });
  178. },
  179. getMaxTime() {
  180. if (!this.phaseData || this.phaseData.length === 0) return this.cycleLength;
  181. const maxDataTime = Math.max(...this.phaseData.map(item => item[2]));
  182. return Math.max(this.cycleLength, maxDataTime);
  183. },
  184. getChartOption() {
  185. const s = this.scaleFactor;
  186. const isTwoRows = this.phaseData.some(item => item[0] === 1);
  187. const yAxisData = isTwoRows ? ['Track 0', 'Track 1'] : ['Track 0'];
  188. const realMaxTime = this.getMaxTime();
  189. return {
  190. backgroundColor: 'transparent',
  191. grid: {
  192. left: 0, right: 0,
  193. // 当隐藏坐标轴/扫描线时(即在表格中显示时),将上下边距设为 0,让色块铺满高度
  194. top: (this.showAxis || this.showScanLineLabel) ? Math.round(35 * s) : 0,
  195. bottom: (this.showAxis || this.showScanLineLabel) ? Math.round(10 * s) : 0,
  196. containLabel: false
  197. },
  198. xAxis: { type: 'value', min: 0, max: realMaxTime, show: false },
  199. yAxis: { type: 'category', data: yAxisData, inverse: true, show: false },
  200. series: [{
  201. type: 'custom',
  202. renderItem: (params, api) => this.renderCustomItem(params, api, isTwoRows, realMaxTime),
  203. encode: { x: [1, 2], y: 0 },
  204. data: this.phaseData,
  205. markLine: !this.showScanLine ? false : {
  206. symbol: ['none', 'none'],
  207. silent: true,
  208. animation: false,
  209. label: {
  210. show: this.showScanLineLabel,
  211. position: 'start', formatter: `${Math.round(this.activeTime)}/${realMaxTime}`,
  212. color: '#fff', backgroundColor: COLORS.MARK_BLUE, padding: [Math.round(4 * s), Math.round(8 * s)],
  213. borderRadius: 2, fontSize: Math.max(10, Math.round(10 * s)),
  214. offset: [0, Math.round(1 * s)]
  215. },
  216. lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(2, Math.round(5 * s)), z: 100 },
  217. data: [ { xAxis: this.activeTime } ]
  218. }
  219. }]
  220. };
  221. },
  222. renderCustomItem(params, api, isTwoRows, realMaxTime) {
  223. const s = this.scaleFactor;
  224. const trackIndex = api.value(0);
  225. const start = api.coord([api.value(1), trackIndex]);
  226. const end = api.coord([api.value(2), trackIndex]);
  227. const blockHeight = api.size([0, 1])[1];
  228. const yPos = start[1] - blockHeight / 2;
  229. const blockWidth = end[0] - start[0];
  230. const phaseName = api.value(3);
  231. const duration = api.value(4);
  232. const type = api.value(5);
  233. const iconValue = api.value(6);
  234. const stageTotal = api.value(8); // 阶段总时长(含绿灯+条纹+黄灯+红灯)
  235. let fillStyle = COLORS.GREEN_LIGHT;
  236. if (type === 'stripe') fillStyle = stripePattern;
  237. else if (type === 'yellow') fillStyle = COLORS.YELLOW;
  238. else if (type === 'red') fillStyle = COLORS.RED;
  239. const rectShape = echarts.graphic.clipRectByRect(
  240. { x: start[0], y: yPos, width: blockWidth, height: blockHeight },
  241. { x: params.coordSys.x, y: params.coordSys.y, width: params.coordSys.width, height: params.coordSys.height }
  242. );
  243. if (!rectShape) return;
  244. const children = [];
  245. // A. 绘制阶段刻度 (S1, S2...)
  246. if (params.dataIndex === 0 && this.showAxis) {
  247. const axisBaseY = params.coordSys.y - Math.round(15 * s);
  248. const track0Data = this.phaseData.filter(item => item[0] === 0);
  249. let stagePoints = track0Data.filter(item => item[5] === 'green').map(item => item[1]);
  250. if (!stagePoints.includes(0)) stagePoints.unshift(0);
  251. stagePoints.push(realMaxTime);
  252. stagePoints = Array.from(new Set(stagePoints)).sort((a, b) => a - b);
  253. stagePoints.forEach(val => {
  254. const x = api.coord([val, 0])[0];
  255. children.push({ type: 'line', shape: { x1: x, y1: axisBaseY - Math.round(5 * s), x2: x, y2: axisBaseY + Math.round(5 * s) }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
  256. });
  257. for (let i = 0; i < stagePoints.length - 1; i++) {
  258. const startX = api.coord([stagePoints[i], 0])[0];
  259. const endX = api.coord([stagePoints[i + 1], 0])[0];
  260. const midX = (startX + endX) / 2;
  261. const textHalf = Math.round(14 * s);
  262. children.push({ type: 'line', shape: { x1: startX, y1: axisBaseY, x2: midX - textHalf, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
  263. children.push({ type: 'line', shape: { x1: midX + textHalf, y1: axisBaseY, x2: endX, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
  264. children.push({ type: 'text', style: { text: `S${i + 1}`, x: midX, y: axisBaseY, fill: COLORS.TEXT_LIGHT, fontSize: Math.max(10, Math.round(14 * s)), align: 'center', verticalAlign: 'middle', fontWeight: 'bold' } });
  265. }
  266. }
  267. // B. 画色块背景
  268. children.push({ type: 'rect', shape: rectShape, style: { fill: fillStyle, stroke: 'none' } });
  269. // C. 绘制内部图标与文本
  270. // 提取基础缩放率
  271. const baseFs = Math.max(0.8, s * 0.9);
  272. // 只要宽度大于 5 像素就尝试去渲染(原版限制是 > 15,改小以支持极限压缩)
  273. if (type === 'green' && blockWidth > 5) {
  274. // --- 1. 将 iconValue 统一解析为数组,提前判断需要多宽的背景 ---
  275. let iconList = [];
  276. if (Array.isArray(iconValue)) {
  277. iconList = iconValue;
  278. } else if (typeof iconValue === 'string' && iconValue.trim() !== '') {
  279. iconList = iconValue.split(',');
  280. } else if (iconValue) {
  281. iconList = [iconValue];
  282. }
  283. // 判断是否有靠左(LT/LB)和靠右(RT/RB)的图标
  284. let hasL = false, hasR = false;
  285. iconList.forEach(icon => {
  286. const valStr = String(icon).trim().toUpperCase();
  287. const posConfig = POS_MAP[valStr] || { pos: 'RB' };
  288. const pos = typeof posConfig === 'string' ? posConfig : (posConfig.pos || 'RB');
  289. if (pos.includes('L')) hasL = true;
  290. if (pos.includes('R')) hasR = true;
  291. });
  292. // 核心:动态赋予深绿色区域的基础宽度!
  293. // 如果左右都有图标,给46宽度;如果只有一侧有,收缩到28;啥都没给8
  294. let idealDarkWidthBase = (hasL && hasR) ? 46 : (iconList.length > 0 ? 28 : 0);
  295. if (idealDarkWidthBase === 0 && phaseName) idealDarkWidthBase = 8;
  296. // 计算当前缩放下,理想状态需要的总像素宽度
  297. let idealDarkWidth = idealDarkWidthBase * baseFs;
  298. let idealTextWidth = 26 * baseFs; // 预留给 "P1\n24" 这类文本的宽度
  299. let totalNeededWidth = idealDarkWidth + 6 * baseFs + idealTextWidth;
  300. // --- 2. 计算动态弹性缩放率 (如果外部方块太小,内部按比例整体缩小) ---
  301. let innerScale = 1;
  302. if (blockWidth < totalNeededWidth) {
  303. // 最极限缩小到 15%,防止变成一个点引发渲染错误
  304. innerScale = Math.max(0.15, blockWidth / totalNeededWidth);
  305. }
  306. // 应用弹性缩放
  307. const dynamicFs = baseFs * innerScale;
  308. const darkWidth = idealDarkWidthBase * dynamicFs;
  309. const midY = yPos + blockHeight / 2;
  310. const pointerW = 4 * dynamicFs; // 中间那个小三角指针的大小也跟着缩放
  311. const innerGroup = {
  312. type: 'group',
  313. // 用 clipPath 限制死边界,防止文字或图标因为四舍五入溢出色块
  314. clipPath: { type: 'rect', shape: { x: start[0], y: yPos, width: blockWidth, height: blockHeight } },
  315. children: [
  316. { type: 'rect', shape: { x: start[0], y: yPos, width: darkWidth, height: blockHeight }, style: { fill: COLORS.GREEN_DARK } },
  317. {
  318. type: 'polygon',
  319. shape: { points: [
  320. [start[0] + darkWidth, midY - pointerW],
  321. [start[0] + darkWidth, midY + pointerW],
  322. [start[0] + darkWidth + pointerW, midY]
  323. ] },
  324. style: { fill: COLORS.GREEN_DARK }
  325. }
  326. ]
  327. };
  328. // --- 3. 绘制内部图标 ---
  329. iconList.forEach(icon => {
  330. const valStr = String(icon).trim().toUpperCase();
  331. const posConfig = POS_MAP[valStr] || { pos: 'RB', padX: 0, padY: 0, baseW: 20, baseH: 20 };
  332. const pos = typeof posConfig === 'string' ? posConfig : (posConfig.pos || 'RB');
  333. // 图标尺寸和边距也应用了 dynamicFs 动态缩放
  334. const drawW = Math.round((posConfig.baseW || 20) * dynamicFs);
  335. const drawH = Math.round((posConfig.baseH || 20) * dynamicFs);
  336. const padX = Math.round((posConfig.padX || 0) * dynamicFs);
  337. const padY = Math.round((posConfig.padY || 0) * dynamicFs);
  338. let iconX, iconY;
  339. if (pos === 'LT') {
  340. iconX = start[0] + padX;
  341. iconY = yPos + padY;
  342. } else if (pos === 'RT') {
  343. iconX = start[0] + darkWidth - drawW - padX;
  344. iconY = yPos + padY;
  345. } else if (pos === 'LB') {
  346. iconX = start[0] + padX;
  347. iconY = yPos + blockHeight - drawH - padY;
  348. } else { // RB
  349. iconX = start[0] + darkWidth - drawW - padX;
  350. iconY = yPos + blockHeight - drawH - padY;
  351. }
  352. if (IMAGE_MAP[valStr]) {
  353. innerGroup.children.push({
  354. type: 'image',
  355. style: {
  356. image: IMAGE_MAP[valStr],
  357. x: iconX,
  358. y: iconY,
  359. width: drawW,
  360. height: drawH,
  361. objectFit: 'contain'
  362. }
  363. });
  364. }
  365. });
  366. // --- 4. 渲染右侧文字 (相位号与时长) ---
  367. // 彻底移除 8px 的硬性下限兜底,让字体完全跟随 dynamicFs 比例等比缩小
  368. const fontSize = Math.max(1, 12 * dynamicFs);
  369. // 计算文本起点的X坐标
  370. const textStartX = start[0] + darkWidth + pointerW + (2 * dynamicFs);
  371. // 如果剩余空间大于 0(有哪怕一丁点空间),才进行文字渲染
  372. if (blockWidth > (darkWidth + pointerW + 2)) {
  373. innerGroup.children.push({
  374. type: 'text',
  375. style: {
  376. text: `${phaseName}\n${stageTotal || duration}`,
  377. x: textStartX,
  378. y: midY,
  379. fill: COLORS.TEXT_DARK,
  380. fontSize: fontSize,
  381. fontWeight: 'bold',
  382. align: 'left',
  383. verticalAlign: 'middle',
  384. // 行高也严格跟随动态字号
  385. lineHeight: fontSize * 1.2
  386. }
  387. });
  388. }
  389. children.push(innerGroup);
  390. }
  391. // D. 轨道分割线 (仅在两排模式下 Track 1 顶部绘制)
  392. if (isTwoRows && trackIndex === 1) {
  393. const dividerY = (api.coord([0, 1])[1] + api.coord([0, 0])[1]) / 2;
  394. children.push({ type: 'line', shape: { x1: start[0], y1: dividerY, x2: end[0], y2: dividerY }, style: { stroke: COLORS.DIVIDER_LINE, lineWidth: Math.max(1, Math.round(1.5 * s)) } });
  395. }
  396. return { type: 'group', children: children };
  397. }
  398. }
  399. };
  400. </script>
  401. <style scoped>
  402. .chart-container {
  403. width: 100%; height: 100%; flex: 1; min-height: 0; overflow: hidden;
  404. }
  405. </style>