| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448 |
- <template>
- <div ref="chartRef" class="chart-container"></div>
- </template>
- <script>
- import * as echarts from 'echarts';
- import echartsResize from '@/mixins/echartsResize.js';
- // 全局心跳定时器:所有 autoScan 实例共享同一个 setInterval
- // 各实例基于 Date.now() 对自身 cycleLength 取模计算位置,天然同步
- let _globalTimer = null;
- let _globalListeners = new Set();
- function joinGlobalTimer(listener) {
- _globalListeners.add(listener);
- if (!_globalTimer) {
- _globalTimer = setInterval(() => {
- const nowSec = Math.floor(Date.now() / 1000);
- _globalListeners.forEach(fn => fn(nowSec));
- }, 1000);
- }
- // 立即触发一次,避免 mounted 到首次 tick 之间的空白
- listener(Math.floor(Date.now() / 1000));
- }
- function leaveGlobalTimer(listener) {
- _globalListeners.delete(listener);
- if (_globalListeners.size === 0 && _globalTimer) {
- clearInterval(_globalTimer);
- _globalTimer = null;
- }
- }
- const COLORS = {
- GREEN_LIGHT: '#8dc453', GREEN_DARK: '#73a542', YELLOW: '#fbd249', RED: '#ff7575', STRIPE_GREEN: '#a3d76e',
- TEXT_DARK: '#1e2638', AXIS_LINE: '#758599', DIVIDER_LINE: '#111827', MARK_BLUE: '#00E5FF', TEXT_LIGHT: '#d1d5db'
- };
- // 绘制条纹图案用于绿闪/预警
- const stripeCanvas = document.createElement('canvas');
- stripeCanvas.width = 4; stripeCanvas.height = 20;
- const ctx = stripeCanvas.getContext('2d');
- ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, 4, 20);
- ctx.fillStyle = COLORS.STRIPE_GREEN; ctx.fillRect(0, 0, 2, 20);
- const stripePattern = { image: stripeCanvas, repeat: 'repeat' };
- const IMAGE_MAP = {
- 'STRAIGHT_DOWN': require('@/assets/images/icon_straight_down.png'),
- 'TURN_DOWN_LEFT': require('@/assets/images/icon_turn_down_left.png'),
- 'TURN_DOWN_LEFT_UTURN': require('@/assets/images/icon_turn_down_left_uturn.png'),
- 'STRAIGHT_UP': require('@/assets/images/icon_straight_up.png'),
- 'TURN_UP_LEFT': require('@/assets/images/icon_turn_up_left.png'),
- 'TURN_UP_LEFT_UTURN': require('@/assets/images/icon_turn_up_left_uturn.png'),
- 'STRAIGHT_LEFT': require('@/assets/images/icon_straight_left.png'),
- 'TURN_LEFT_DOWN': require('@/assets/images/icon_turn_left_down.png'),
- 'TURN_LEFT_DOWN_UTURN': require('@/assets/images/icon_turn_left_down_uturn.png'),
- 'STRAIGHT_RIGHT': require('@/assets/images/icon_straight_right.png'),
- 'TURN_RIGHT_UP': require('@/assets/images/icon_turn_right_up.png'),
- 'TURN_RIGHT_UP_UTURN': require('@/assets/images/icon_turn_right_up_uturn.png' )
- };
- // ==========================================
- // 核心逻辑:基于真实物理空间的对齐与自定义偏移/尺寸配置
- // pos: 位置(LT/RT/LB/RB), padX/padY: 基础像素偏移, baseW/baseH: 基础原始宽高
- // ==========================================
- const POS_MAP = {
- // 1. 上方驶入 -> 靠左上角 (LT)
- 'STRAIGHT_DOWN': { pos: 'LT', padX: 10, padY: 0, baseW: 7, baseH: 20.67 },
- 'TURN_DOWN_LEFT': { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },
- 'TURN_DOWN_LEFT_UTURN': { pos: 'LT', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
-
- // 2. 下方驶入 -> 靠右下角 (RB)
- 'STRAIGHT_UP': { pos: 'RB', padX: 10, padY: 0, baseW: 7, baseH: 20.67 },
- 'TURN_UP_LEFT': { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 21.33 },
- 'TURN_UP_LEFT_UTURN': { pos: 'RB', padX: 10, padY: 0, baseW: 13, baseH: 22.67 },
-
- // 3. 右侧驶入 -> 靠右上角 (RT)
- 'STRAIGHT_LEFT': { pos: 'RT', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },
- 'TURN_LEFT_DOWN': { pos: 'RT', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },
- 'TURN_LEFT_DOWN_UTURN': { pos: 'RT', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
-
- // 4. 左侧驶入 -> 靠左下角 (LB)
- 'STRAIGHT_RIGHT': { pos: 'LB', padX: 0, padY: 10, baseW: 20.33, baseH: 6.33 },
- 'TURN_RIGHT_UP': { pos: 'LB', padX: 0, padY: 10, baseW: 20.67, baseH: 12.33 },
- 'TURN_RIGHT_UP_UTURN': { pos: 'LB', padX: 0, padY: 10, baseW: 22.67, baseH: 12.33 },
- };
- export default {
- name: 'SignalTimingChart',
- mixins: [echartsResize],
- props: {
- cycleLength: { type: Number, default: 140 },
- currentTime: { type: Number, default: 0 },
- phaseData: { type: Array, default: () => [] },
- showAxis: { type: Boolean, default: true },
- showScanLine: { type: Boolean, default: true },
- showScanLineLabel: { type: Boolean, default: true },
- autoScan: { type: Boolean, default: false }
- },
- data() {
- return { scaleFactor: 1, internalTime: 0 };
- },
- computed: {
- activeTime() {
- return this.autoScan ? this.internalTime : this.currentTime;
- }
- },
- mounted() {
- this.internalTime = this.currentTime;
- this.initChart();
- if (this.autoScan) this.startAutoScan();
- },
- beforeDestroy() {
- this.stopAutoScan();
- },
- watch: {
- currentTime(val) {
- if (!this.autoScan) {
- if (this.$_chart) this.updateScanLine();
- }
- },
- autoScan(val) {
- if (val) { this.startAutoScan(); } else { this.stopAutoScan(); }
- },
- showScanLine(val) {
- this.updateChart();
- if (val && this.autoScan) { this.startAutoScan(); }
- },
- phaseData: { deep: true, handler(newVal) { if (this.$_chart && newVal.length > 0) this.updateChart(); } },
- showAxis() { this.updateChart(); },
- showScanLineLabel() { this.updateChart(); }
- },
- methods: {
- updateScale() {
- const el = this.$el;
- if (!el) return;
- this.scaleFactor = Math.max(0.5, el.clientWidth / 600);
- },
- startAutoScan() {
- this.stopAutoScan();
- // 全局心跳 + 绝对时间取模:相同 cycleLength 的实例扫描线天然同步
- this._scanListener = (nowSec) => {
- const realMax = this.getMaxTime();
- this.internalTime = nowSec % realMax;
- if (this.$_chart) this.updateScanLine();
- this.$emit('scan-tick', this.internalTime);
- };
- joinGlobalTimer(this._scanListener);
- },
- stopAutoScan() {
- if (this._scanListener) { leaveGlobalTimer(this._scanListener); this._scanListener = null; }
- },
- initChart() {
- const chartDom = this.$refs.chartRef;
- if (!chartDom) return;
- this.updateScale();
- this.$_chart = echarts.init(chartDom);
- if (this.phaseData.length > 0) this.updateChart();
- },
- updateChart() {
- if (!this.$_chart) return;
- this.updateScale();
- this.$_chart.setOption(this.getChartOption(), true);
- },
- updateScanLine() {
- if (!this.$_chart) return;
- this.updateScale();
- const s = this.scaleFactor;
- const realMaxTime = this.getMaxTime();
- this.$_chart.setOption({
- series: [{
- markLine: !this.showScanLine ? false : {
- symbol: ['none', 'none'],
- silent: true,
- animation: false,
- label: {
- show: this.showScanLineLabel,
- position: 'start',
- formatter: `${Math.round(this.activeTime)}/${realMaxTime}`,
- color: '#fff', backgroundColor: COLORS.MARK_BLUE,
- padding: [Math.round(4 * s), Math.round(8 * s)],
- borderRadius: 2, fontSize: Math.max(10, Math.round(10 * s)),
- offset: [0, Math.round(1 * s)]
- },
- lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(2, Math.round(5 * s)), z: 100 },
- data: [{ xAxis: this.activeTime }]
- }
- }]
- });
- },
- getMaxTime() {
- if (!this.phaseData || this.phaseData.length === 0) return this.cycleLength;
- const maxDataTime = Math.max(...this.phaseData.map(item => item[2]));
- return Math.max(this.cycleLength, maxDataTime);
- },
- getChartOption() {
- const s = this.scaleFactor;
- const isTwoRows = this.phaseData.some(item => item[0] === 1);
- const yAxisData = isTwoRows ? ['Track 0', 'Track 1'] : ['Track 0'];
- const realMaxTime = this.getMaxTime();
- return {
- backgroundColor: 'transparent',
- grid: {
- left: 0, right: 0,
- // 当隐藏坐标轴/扫描线时(即在表格中显示时),将上下边距设为 0,让色块铺满高度
- top: (this.showAxis || this.showScanLineLabel) ? Math.round(35 * s) : 0,
- bottom: (this.showAxis || this.showScanLineLabel) ? Math.round(10 * s) : 0,
- containLabel: false
- },
- xAxis: { type: 'value', min: 0, max: realMaxTime, show: false },
- yAxis: { type: 'category', data: yAxisData, inverse: true, show: false },
- series: [{
- type: 'custom',
- renderItem: (params, api) => this.renderCustomItem(params, api, isTwoRows, realMaxTime),
- encode: { x: [1, 2], y: 0 },
- data: this.phaseData,
- markLine: !this.showScanLine ? false : {
- symbol: ['none', 'none'],
- silent: true,
- animation: false,
- label: {
- show: this.showScanLineLabel,
- position: 'start', formatter: `${Math.round(this.activeTime)}/${realMaxTime}`,
- color: '#fff', backgroundColor: COLORS.MARK_BLUE, padding: [Math.round(4 * s), Math.round(8 * s)],
- borderRadius: 2, fontSize: Math.max(10, Math.round(10 * s)),
- offset: [0, Math.round(1 * s)]
- },
- lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: Math.max(2, Math.round(5 * s)), z: 100 },
- data: [ { xAxis: this.activeTime } ]
- }
- }]
- };
- },
- renderCustomItem(params, api, isTwoRows, realMaxTime) {
- const s = this.scaleFactor;
- const trackIndex = api.value(0);
- const start = api.coord([api.value(1), trackIndex]);
- const end = api.coord([api.value(2), trackIndex]);
- const blockHeight = api.size([0, 1])[1];
- const yPos = start[1] - blockHeight / 2;
- const blockWidth = end[0] - start[0];
- const phaseName = api.value(3);
- const duration = api.value(4);
- const type = api.value(5);
- const iconValue = api.value(6);
- const stageTotal = api.value(8); // 阶段总时长(含绿灯+条纹+黄灯+红灯)
- let fillStyle = COLORS.GREEN_LIGHT;
- if (type === 'stripe') fillStyle = stripePattern;
- else if (type === 'yellow') fillStyle = COLORS.YELLOW;
- else if (type === 'red') fillStyle = COLORS.RED;
- const rectShape = echarts.graphic.clipRectByRect(
- { x: start[0], y: yPos, width: blockWidth, height: blockHeight },
- { x: params.coordSys.x, y: params.coordSys.y, width: params.coordSys.width, height: params.coordSys.height }
- );
- if (!rectShape) return;
- const children = [];
- // A. 绘制阶段刻度 (S1, S2...)
- if (params.dataIndex === 0 && this.showAxis) {
- const axisBaseY = params.coordSys.y - Math.round(15 * s);
- const track0Data = this.phaseData.filter(item => item[0] === 0);
- let stagePoints = track0Data.filter(item => item[5] === 'green').map(item => item[1]);
- if (!stagePoints.includes(0)) stagePoints.unshift(0);
- stagePoints.push(realMaxTime);
- stagePoints = Array.from(new Set(stagePoints)).sort((a, b) => a - b);
- stagePoints.forEach(val => {
- const x = api.coord([val, 0])[0];
- 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)) } });
- });
- for (let i = 0; i < stagePoints.length - 1; i++) {
- const startX = api.coord([stagePoints[i], 0])[0];
- const endX = api.coord([stagePoints[i + 1], 0])[0];
- const midX = (startX + endX) / 2;
- const textHalf = Math.round(14 * s);
- 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)) } });
- 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)) } });
- 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' } });
- }
- }
- // B. 画色块背景
- children.push({ type: 'rect', shape: rectShape, style: { fill: fillStyle, stroke: 'none' } });
- // C. 绘制内部图标与文本
- // 提取基础缩放率
- const baseFs = Math.max(0.8, s * 0.9);
- // 只要宽度大于 5 像素就尝试去渲染(原版限制是 > 15,改小以支持极限压缩)
- if (type === 'green' && blockWidth > 5) {
-
- // --- 1. 将 iconValue 统一解析为数组,提前判断需要多宽的背景 ---
- let iconList = [];
- if (Array.isArray(iconValue)) {
- iconList = iconValue;
- } else if (typeof iconValue === 'string' && iconValue.trim() !== '') {
- iconList = iconValue.split(',');
- } else if (iconValue) {
- iconList = [iconValue];
- }
- // 判断是否有靠左(LT/LB)和靠右(RT/RB)的图标
- let hasL = false, hasR = false;
- iconList.forEach(icon => {
- const valStr = String(icon).trim().toUpperCase();
- const posConfig = POS_MAP[valStr] || { pos: 'RB' };
- const pos = typeof posConfig === 'string' ? posConfig : (posConfig.pos || 'RB');
- if (pos.includes('L')) hasL = true;
- if (pos.includes('R')) hasR = true;
- });
-
- // 核心:动态赋予深绿色区域的基础宽度!
- // 如果左右都有图标,给46宽度;如果只有一侧有,收缩到28;啥都没给8
- let idealDarkWidthBase = (hasL && hasR) ? 46 : (iconList.length > 0 ? 28 : 0);
- if (idealDarkWidthBase === 0 && phaseName) idealDarkWidthBase = 8;
- // 计算当前缩放下,理想状态需要的总像素宽度
- let idealDarkWidth = idealDarkWidthBase * baseFs;
- let idealTextWidth = 26 * baseFs; // 预留给 "P1\n24" 这类文本的宽度
- let totalNeededWidth = idealDarkWidth + 6 * baseFs + idealTextWidth;
- // --- 2. 计算动态弹性缩放率 (如果外部方块太小,内部按比例整体缩小) ---
- let innerScale = 1;
- if (blockWidth < totalNeededWidth) {
- // 最极限缩小到 15%,防止变成一个点引发渲染错误
- innerScale = Math.max(0.15, blockWidth / totalNeededWidth);
- }
- // 应用弹性缩放
- const dynamicFs = baseFs * innerScale;
- const darkWidth = idealDarkWidthBase * dynamicFs;
- const midY = yPos + blockHeight / 2;
- const pointerW = 4 * dynamicFs; // 中间那个小三角指针的大小也跟着缩放
-
- const innerGroup = {
- type: 'group',
- // 用 clipPath 限制死边界,防止文字或图标因为四舍五入溢出色块
- clipPath: { type: 'rect', shape: { x: start[0], y: yPos, width: blockWidth, height: blockHeight } },
- children: [
- { type: 'rect', shape: { x: start[0], y: yPos, width: darkWidth, height: blockHeight }, style: { fill: COLORS.GREEN_DARK } },
- {
- type: 'polygon',
- shape: { points: [
- [start[0] + darkWidth, midY - pointerW],
- [start[0] + darkWidth, midY + pointerW],
- [start[0] + darkWidth + pointerW, midY]
- ] },
- style: { fill: COLORS.GREEN_DARK }
- }
- ]
- };
- // --- 3. 绘制内部图标 ---
- iconList.forEach(icon => {
- const valStr = String(icon).trim().toUpperCase();
- const posConfig = POS_MAP[valStr] || { pos: 'RB', padX: 0, padY: 0, baseW: 20, baseH: 20 };
- const pos = typeof posConfig === 'string' ? posConfig : (posConfig.pos || 'RB');
-
- // 图标尺寸和边距也应用了 dynamicFs 动态缩放
- const drawW = Math.round((posConfig.baseW || 20) * dynamicFs);
- const drawH = Math.round((posConfig.baseH || 20) * dynamicFs);
- const padX = Math.round((posConfig.padX || 0) * dynamicFs);
- const padY = Math.round((posConfig.padY || 0) * dynamicFs);
- let iconX, iconY;
- if (pos === 'LT') {
- iconX = start[0] + padX;
- iconY = yPos + padY;
- } else if (pos === 'RT') {
- iconX = start[0] + darkWidth - drawW - padX;
- iconY = yPos + padY;
- } else if (pos === 'LB') {
- iconX = start[0] + padX;
- iconY = yPos + blockHeight - drawH - padY;
- } else { // RB
- iconX = start[0] + darkWidth - drawW - padX;
- iconY = yPos + blockHeight - drawH - padY;
- }
- if (IMAGE_MAP[valStr]) {
- innerGroup.children.push({
- type: 'image',
- style: {
- image: IMAGE_MAP[valStr],
- x: iconX,
- y: iconY,
- width: drawW,
- height: drawH,
- objectFit: 'contain'
- }
- });
- }
- });
-
- // --- 4. 渲染右侧文字 (相位号与时长) ---
- // 彻底移除 8px 的硬性下限兜底,让字体完全跟随 dynamicFs 比例等比缩小
- const fontSize = Math.max(1, 12 * dynamicFs);
-
- // 计算文本起点的X坐标
- const textStartX = start[0] + darkWidth + pointerW + (2 * dynamicFs);
- // 如果剩余空间大于 0(有哪怕一丁点空间),才进行文字渲染
- if (blockWidth > (darkWidth + pointerW + 2)) {
- innerGroup.children.push({
- type: 'text',
- style: {
- text: `${phaseName}\n${stageTotal || duration}`,
- x: textStartX,
- y: midY,
- fill: COLORS.TEXT_DARK,
- fontSize: fontSize,
- fontWeight: 'bold',
- align: 'left',
- verticalAlign: 'middle',
- // 行高也严格跟随动态字号
- lineHeight: fontSize * 1.2
- }
- });
- }
- children.push(innerGroup);
- }
-
- // D. 轨道分割线 (仅在两排模式下 Track 1 顶部绘制)
- if (isTwoRows && trackIndex === 1) {
- const dividerY = (api.coord([0, 1])[1] + api.coord([0, 0])[1]) / 2;
- 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)) } });
- }
- return { type: 'group', children: children };
- }
- }
- };
- </script>
- <style scoped>
- .chart-container {
- width: 100%; height: 100%; flex: 1; min-height: 0; overflow: hidden;
- }
- </style>
|