| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484 |
- <template>
- <div ref="chartRef" class="traffic-timespace-chart"></div>
- </template>
- <script>
- import * as echarts from 'echarts';
- import echartsResize, { px2echarts } from '@/mixins/echartsResize.js';
- export default {
- name: 'TrafficTimeSpace',
- mixins: [echartsResize],
- props: {
- // 【终极修复】:使用 0, 50, 0, 0 神级协调相位差
- roadSegments: {
- type: Array,
- required: true,
- default: () => [
- { name: '交叉口A', distanceNext: 450, offset: 0 },
- { name: '交叉口B', distanceNext: 669, offset: 50 },
- { name: '交叉口C', distanceNext: 1050, offset: 0 },
- { name: '交叉口D', distanceNext: 0, offset: 0 }
- ]
- },
- speedKmh: { type: Number, default: 38.9 }, // 设计车速
- cycle: { type: Number, default: 100 }, // 红绿灯周期
- greenDuration: { type: Number, default: 40 }, // 绿灯时长
- bandwidth: { type: Number, default: 31.5 }, // 波带宽(秒)
- viewWindow: { type: Number, default: 420 },
- autoScroll: { type: Boolean, default: false },
- scrollSpeed: { type: Number, default: 0.2 },
- upWaveColor: { type: String, default: 'rgba(46, 204, 113, 0.45)' },
- downWaveColor: { type: String, default: 'rgba(52, 152, 219, 0.45)' },
- showYSplitLine: { type: Boolean, default: false },
- showPhaseOffset: { type: Boolean, default: true },
- showScanLine: { type: Boolean, default: true },
- scanLineColor: { type: String, default: 'rgba(64, 158, 255, 0.9)' },
- scanLineStart: { type: Number, default: 0 }
- },
- data() {
- return {
- $_chart: null,
- scrollTimer: null,
- scanLineTimer: null,
- currentViewMinX: 0,
- currentScanX: 0,
- };
- },
- computed: {
- barWidth() {
- const scale = typeof window !== 'undefined' ? Math.max(1, window.innerWidth / 1920) : 1;
- return Math.round(8 * scale);
- },
- gap() {
- const scale = typeof window !== 'undefined' ? Math.max(1, window.innerWidth / 1920) : 1;
- return Math.round(2 * scale);
- },
- intersections() {
- let currentX = 0;
- return this.roadSegments.map((seg, i) => {
- const intersection = {
- id: `I${i}`,
- name: seg.name,
- distanceNext: seg.distanceNext,
- x: currentX,
- offsetText: `${seg.offset}秒`,
- offset: seg.offset
- };
- currentX += seg.distanceNext;
- return intersection;
- });
- },
- maxX() {
- const last = this.intersections[this.intersections.length - 1];
- return last.x + 80;
- },
- yAxisMin() {
- // 留出底部 padding,避免首个路口(distance=0)向下延伸的柱子被 clip 裁掉
- return -80;
- },
- maxDataTime() {
- return this.viewWindow * 3;
- }
- },
- mounted() {
- this.$nextTick(() => {
- this.initChart();
- if (this.autoScroll) this.startScroll();
- if (this.showScanLine) this.startScanLine();
- });
- },
- beforeDestroy() {
- this.stopScroll();
- this.stopScanLine();
- if (this.$_chart) this.$_chart.dispose();
- },
- watch: {
- roadSegments: { handler() { this.updateChart(); }, deep: true },
- speedKmh() { this.updateChart(); },
- autoScroll(val) { val ? this.startScroll() : this.stopScroll(); }
- },
- methods: {
- // 根据大屏宽度缩放字号/像素常量,避免大屏下文字过小
- fs(px) {
- const scaled = typeof px2echarts === 'function' ? px2echarts(px) : px;
- return Math.max(px, scaled);
- },
- initChart() {
- if (!this.$refs.chartRef) return;
- this.$_chart = echarts.init(this.$refs.chartRef);
- this.updateChart();
- },
- generateBarData() {
- const data = [];
- const colors = { red: '#e74c3c', green: '#2ecc71', blue: '#3498db' };
- this.intersections.forEach(intersection => {
- for (let k = -2; k <= Math.ceil(this.maxDataTime / this.cycle) + 2; k++) {
- const pStart = intersection.offset + k * this.cycle;
- const pEnd = pStart + this.greenDuration;
- const rStart = pEnd;
- const rEnd = pStart + this.cycle;
- data.push([intersection.x, pStart, pEnd, colors.green, -1]);
- data.push([intersection.x, pStart, pEnd, colors.blue, 1]);
- data.push([intersection.x, rStart, rEnd, colors.red, -1]);
- data.push([intersection.x, rStart, rEnd, colors.red, 1]);
- }
- });
- return data;
- },
- getWaveData() {
- const speedMs = this.speedKmh / 3.6; // 设计车速换算为 m/s
- const startX = this.intersections[0].x;
- const endX = this.intersections[this.intersections.length - 1].x;
- const travelTime = (endX - startX) / speedMs;
- // 正向绿波 (A -> D):gStartY 秒起步
- const gStartY = 0;
- const greenBottomLine = [
- [startX, gStartY],
- [endX, gStartY + travelTime]
- ];
- const greenTopLine = [
- [startX, gStartY + this.bandwidth],
- [endX, gStartY + travelTime + this.bandwidth]
- ];
- const greenCoords = [...greenBottomLine, ...[...greenTopLine].reverse()];
- // 反向蓝波 (D -> A):bStartYAtD 秒起步
- const bStartYAtD = 100;
- const blueBottomLine = [
- [endX, bStartYAtD],
- [startX, bStartYAtD + travelTime]
- ];
- const blueTopLine = [
- [endX, bStartYAtD + this.bandwidth],
- [startX, bStartYAtD + travelTime + this.bandwidth]
- ];
- const blueCoords = [...blueBottomLine, ...[...blueTopLine].reverse()];
- return [
- {
- coords: greenCoords,
- bottomLine: greenBottomLine,
- topLine: greenTopLine,
- color: this.upWaveColor,
- lineCol: '#2ecc71',
- isBlue: false
- },
- {
- coords: blueCoords,
- bottomLine: blueBottomLine,
- topLine: blueTopLine,
- color: this.downWaveColor,
- lineCol: '#3498db',
- isBlue: true
- }
- ];
- },
- updateChart() {
- if (!this.$_chart) return;
- // x 轴刻度仅对齐首路口(A)的红块首尾
- const gcd = (a, b) => (b === 0 ? Math.abs(a) : gcd(b, a % b));
- const firstOffset = this.intersections[0] ? this.intersections[0].offset : 0;
- const redStartMod = ((firstOffset + this.greenDuration) % this.cycle + this.cycle) % this.cycle; // 红块起点
- const redEndMod = ((firstOffset) % this.cycle + this.cycle) % this.cycle; // 红块终点 = 下周期起点
- // interval 需同时整除 redStartMod 与 redEndMod 的间距,保证 label 落点
- const tickInterval = gcd(gcd(this.greenDuration, this.cycle - this.greenDuration), firstOffset || this.cycle);
- const option = {
- backgroundColor: 'transparent',
- animation: false,
- tooltip: { show: false },
- grid: { left: this.fs(140), right: this.fs(40), top: this.fs(40), bottom: this.fs(50) },
- xAxis: {
- type: 'value', min: this.currentViewMinX, max: this.currentViewMinX + this.viewWindow,
- interval: tickInterval, name: '时间 (秒)',
- nameLocation: 'end',
- nameTextStyle: { color: '#a0aabf', padding: [0, 0, 0, 0], fontSize: this.fs(12) },
- axisLine: { show: true, onZero: false, lineStyle: { color: 'rgba(255,255,255,0.5)' } },
- axisTick: {
- show: true,
- lineStyle: { color: 'rgba(255,255,255,0.5)' },
- length: this.fs(5)
- },
- axisLabel: {
- color: '#a0aabf',
- fontSize: this.fs(10),
- formatter: (value) => {
- const mod = ((value % this.cycle) + this.cycle) % this.cycle;
- return (mod === redStartMod || mod === redEndMod) ? value : '';
- }
- },
- splitLine: { show: this.showYSplitLine, lineStyle: { type: 'dashed', color: 'rgba(255, 255, 255, 0.08)' } }
- },
- yAxis: {
- type: 'value', min: this.yAxisMin, max: this.maxX,
- axisLine: { show: true, onZero: false, lineStyle: { color: 'rgba(255,255,255,0.15)' } },
- axisTick: { show: false },
- axisLabel: { show: false },
- splitLine: { show: false }
- },
- series: [
- {
- id: 'waveSeries',
- type: 'custom',
- clip: true,
- data: this.getWaveData(),
- renderItem: (params, api) => {
- const item = this.getWaveData()[params.dataIndex];
- const pixelOffsetY = item.isBlue ? (this.barWidth * 1.5 + this.gap) : (this.barWidth / 2);
- // coords 原始格式 [距离, 时间],互换轴后映射为 api.coord([时间, 距离])
- const mapPt = c => {
- const pt = api.coord([c[1], c[0]]);
- return [pt[0], pt[1] + pixelOffsetY];
- };
- const mappedCoords = item.coords.map(mapPt);
- const mappedBottom = item.bottomLine.map(mapPt);
- const mappedTop = item.topLine.map(mapPt);
- const segIdx = 0;
- const pB1 = mappedBottom[segIdx];
- const pB2 = mappedBottom[segIdx + 1];
- const pT1 = mappedTop[segIdx];
- const pT2 = mappedTop[segIdx + 1];
- // 波带角度统一用底线方向(atan2),保证文字沿着波带方向读
- const angle = Math.atan2(pB2[1] - pB1[1], pB2[0] - pB1[0]);
- // 绿波速度文字沿波带方向顺时针 90°;蓝波沿波带方向逆时针 90°
- const speedAngle = item.isBlue ? angle - Math.PI / 2 : angle + Math.PI / 2;
- const percent = 0.15;
- const midBottomX = pB1[0] + (pB2[0] - pB1[0]) * percent;
- const midBottomY = pB1[1] + (pB2[1] - pB1[1]) * percent;
- const midTopX = pT1[0] + (pT2[0] - pT1[0]) * percent;
- const midTopY = pT1[1] + (pT2[1] - pT1[1]) * percent;
- // 速度文字沿波带法线向外偏移;与虚线保持同侧(绿波→顶线侧,蓝波→底线侧)
- const sBDx = pB2[0] - pB1[0];
- const sBDy = pB2[1] - pB1[1];
- const sBLen = Math.sqrt(sBDx * sBDx + sBDy * sBDy) || 1;
- const sPerpX = sBDy / sBLen;
- const sPerpY = -sBDx / sBLen;
- const sDot = (pT1[0] - pB1[0]) * sPerpX + (pT1[1] - pB1[1]) * sPerpY;
- const speedOff = this.fs(14);
- let speedX, speedY;
- if (item.isBlue) {
- // 蓝波:底线外侧
- const sSign = sDot > 0 ? -1 : 1;
- speedX = midBottomX + sSign * sPerpX * speedOff;
- speedY = midBottomY + sSign * sPerpY * speedOff;
- } else {
- // 绿波:顶线外侧(虚线这边)
- const sSign = sDot > 0 ? 1 : -1;
- speedX = midTopX + sSign * sPerpX * speedOff;
- speedY = midTopY + sSign * sPerpY * speedOff;
- }
- return {
- type: 'group',
- children: [
- { type: 'polygon', shape: { points: mappedCoords }, style: { fill: item.color } },
- ...(() => {
- // 虚线沿垂直于波带方向向外偏移:绿波走顶线外侧,蓝波走底线外侧(互相镜像)
- const lineGap = this.fs(4);
- const bDx = mappedBottom[1][0] - mappedBottom[0][0];
- const bDy = mappedBottom[1][1] - mappedBottom[0][1];
- const bLen = Math.sqrt(bDx * bDx + bDy * bDy) || 1;
- const perpX = bDy / bLen;
- const perpY = -bDx / bLen;
- const midBx = (mappedBottom[0][0] + mappedBottom[1][0]) / 2;
- const midBy = (mappedBottom[0][1] + mappedBottom[1][1]) / 2;
- const midTx = (mappedTop[0][0] + mappedTop[1][0]) / 2;
- const midTy = (mappedTop[0][1] + mappedTop[1][1]) / 2;
- const dot = (midTx - midBx) * perpX + (midTy - midBy) * perpY;
- let points;
- if (item.isBlue) {
- // 蓝波:底线外侧(远离顶线)
- const sign = dot > 0 ? -1 : 1;
- points = mappedBottom.map(p => [p[0] + sign * perpX * lineGap, p[1] + sign * perpY * lineGap]);
- } else {
- // 绿波:顶线外侧(远离底线)
- const sign = dot > 0 ? 1 : -1;
- points = mappedTop.map(p => [p[0] + sign * perpX * lineGap, p[1] + sign * perpY * lineGap]);
- }
- return [
- { type: 'polyline', shape: { points }, style: { stroke: item.lineCol, lineDash: [4, 4], lineWidth: 1 } }
- ];
- })(),
- { type: 'text', x: speedX, y: speedY, rotation: speedAngle, style: { text: `${this.speedKmh}km/h`, fill: '#fff', fontSize: this.fs(11), textAlign: 'center' } },
- ...(() => {
- const cxBw = (midBottomX + midTopX) / 2;
- const cyBw = (midBottomY + midTopY) / 2;
- const dxBw = midTopX - midBottomX;
- const dyBw = midTopY - midBottomY;
- const lenBw = Math.sqrt(dxBw * dxBw + dyBw * dyBw) || 1;
- const uxBw = dxBw / lenBw;
- const uyBw = dyBw / lenBw;
- const gapBw = this.fs(10);
- return [
- { type: 'line', shape: { x1: midBottomX, y1: midBottomY, x2: cxBw - uxBw * gapBw, y2: cyBw - uyBw * gapBw }, style: { stroke: 'rgba(255, 255, 255, 0.4)' } },
- { type: 'text', x: cxBw, y: cyBw, rotation: angle, style: { text: `${this.bandwidth}s`, fill: '#fff', fontSize: this.fs(11), textAlign: 'center', textVerticalAlign: 'middle' } },
- { type: 'line', shape: { x1: cxBw + uxBw * gapBw, y1: cyBw + uyBw * gapBw, x2: midTopX, y2: midTopY }, style: { stroke: 'rgba(255, 255, 255, 0.4)' } }
- ];
- })()
- ]
- };
- }
- },
- {
- id: 'lightSeries',
- type: 'custom',
- clip: true,
- data: this.generateBarData(),
- renderItem: (params, api) => {
- const distance = api.value(0);
- const tStart = api.value(1);
- const tEnd = api.value(2);
- const color = api.value(3);
- const direction = api.value(4);
- const startP = api.coord([tStart, distance]);
- const endP = api.coord([tEnd, distance]);
- const rectY = direction === -1 ? startP[1] : (startP[1] + this.barWidth + this.gap);
- return {
- type: 'rect',
- shape: { x: startP[0], y: rectY, width: endP[0] - startP[0], height: this.barWidth },
- style: { fill: color }
- };
- }
- },
- {
- id: 'axisLabels',
- type: 'custom',
- clip: false,
- data: this.intersections,
- renderItem: (params, api) => {
- const intersection = this.intersections[params.dataIndex];
- const point = api.coord([this.currentViewMinX, intersection.x]);
- const centerYPx = point[1] + this.barWidth + this.gap / 2;
- const labelX = point[0] - this.fs(12);
- const nameFs = this.fs(12);
- const subFs = this.fs(11);
- // 名称较长时按 "-" 拆成两行显示
- const dashIdx = intersection.name.indexOf('-');
- const nameLine1 = dashIdx >= 0 ? intersection.name.slice(0, dashIdx) : intersection.name;
- const nameLine2 = dashIdx >= 0 ? intersection.name.slice(dashIdx + 1) : '';
- const children = [];
- if (nameLine2) {
- children.push({ type: 'text', x: labelX, y: centerYPx - this.fs(15), style: { text: nameLine1, fill: '#fff', textAlign: 'right', fontSize: nameFs } });
- children.push({ type: 'text', x: labelX, y: centerYPx - this.fs(1), style: { text: nameLine2, fill: '#fff', textAlign: 'right', fontSize: nameFs } });
- } else {
- children.push({ type: 'text', x: labelX, y: centerYPx - this.fs(7), style: { text: nameLine1, fill: '#fff', textAlign: 'right', fontSize: nameFs } });
- }
- if (this.showPhaseOffset) {
- const offYPx = nameLine2 ? centerYPx + this.fs(14) : centerYPx + this.fs(5);
- children.push({ type: 'text', x: labelX, y: offYPx, style: { text: `相位差: ${intersection.offsetText}`, fill: '#a0aabf', textAlign: 'right', fontSize: subFs } });
- }
- if (intersection.distanceNext) {
- const nextP = api.coord([this.currentViewMinX, intersection.x + intersection.distanceNext]);
- const nextCenterYPx = nextP[1] + this.barWidth + this.gap / 2;
- const midYPx = (centerYPx + nextCenterYPx) / 2;
- // 非反转 yAxis:距离越大像素 y 越小,nextCenterYPx < centerYPx
- const lineX = labelX - this.fs(10);
- 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] } });
- children.push({ type: 'text', x: lineX, y: midYPx, style: { text: `${intersection.distanceNext}m`, fill: '#a0aabf', textAlign: 'center', fontSize: subFs } });
- }
- return { type: 'group', children };
- }
- },
- ...(this.showScanLine ? [{
- id: 'scanLineSeries',
- type: 'custom',
- clip: true,
- data: [[this.currentScanX]],
- renderItem: (params, api) => {
- const xTime = api.value(0);
- const top = api.coord([xTime, this.yAxisMin]);
- const bottom = api.coord([xTime, this.maxX]);
- return {
- type: 'line',
- shape: { x1: top[0], y1: top[1], x2: bottom[0], y2: bottom[1] },
- style: { stroke: this.scanLineColor, lineWidth: this.fs(4) }
- };
- },
- z: 10
- }] : [])
- ]
- };
- this.$_chart.setOption(option, { replaceMerge: ['series'] });
- },
- startScanLine() {
- this.stopScanLine();
- this.currentScanX = this.viewWindow * Math.min(1, Math.max(0, this.scanLineStart));
- const step = this.viewWindow / 100;
- this.scanLineTimer = setInterval(() => {
- this.currentScanX += step;
- if (this.currentScanX > this.currentViewMinX + this.viewWindow) {
- this.currentScanX = this.currentViewMinX;
- }
- if (this.$_chart) {
- this.$_chart.setOption({
- series: [{ id: 'scanLineSeries', data: [[this.currentScanX]] }]
- });
- }
- }, 1000);
- },
- stopScanLine() {
- if (this.scanLineTimer) {
- clearInterval(this.scanLineTimer);
- this.scanLineTimer = null;
- }
- },
- startScroll() {
- this.stopScroll();
- this.scrollTimer = setInterval(() => {
- this.currentViewMinX += this.scrollSpeed;
- if (this.currentViewMinX > this.maxDataTime - this.viewWindow) {
- this.currentViewMinX = 0;
- }
- if (this.$_chart) {
- this.$_chart.setOption({
- xAxis: { min: this.currentViewMinX, max: this.currentViewMinX + this.viewWindow }
- });
- }
- }, 16);
- },
- stopScroll() {
- if (this.scrollTimer) {
- clearInterval(this.scrollTimer);
- this.scrollTimer = null;
- }
- }
- }
- };
- </script>
- <style scoped>
- .traffic-timespace-chart {
- width: 100%;
- height: 100%;
- min-height: 0;
- flex: 1;
- }
- </style>
|