|
|
@@ -4,15 +4,15 @@
|
|
|
|
|
|
<script>
|
|
|
import * as echarts from 'echarts';
|
|
|
-import echartsResize, { px2echarts } from '@/mixins/echartsResize.js';
|
|
|
+import echartsResize, { px2echarts } from '@/mixins/echartsResize.js';
|
|
|
|
|
|
export default {
|
|
|
name: 'TrafficTimeSpace',
|
|
|
- mixins: [echartsResize],
|
|
|
+ mixins: [echartsResize],
|
|
|
props: {
|
|
|
// 【终极修复】:使用 0, 50, 0, 0 神级协调相位差
|
|
|
- roadSegments: {
|
|
|
- type: Array,
|
|
|
+ roadSegments: {
|
|
|
+ type: Array,
|
|
|
required: true,
|
|
|
default: () => [
|
|
|
{ name: '交叉口A', distanceNext: 450, offset: 0 },
|
|
|
@@ -25,17 +25,17 @@ export default {
|
|
|
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 },
|
|
|
-
|
|
|
+
|
|
|
+ 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(255, 60, 60, 0.8)' },
|
|
|
+ scanLineColor: { type: String, default: 'rgba(64, 158, 255, 0.9)' },
|
|
|
scanLineStart: { type: Number, default: 0 }
|
|
|
},
|
|
|
data() {
|
|
|
@@ -43,7 +43,7 @@ export default {
|
|
|
$_chart: null,
|
|
|
scrollTimer: null,
|
|
|
scanLineTimer: null,
|
|
|
- currentViewMinY: 0,
|
|
|
+ currentViewMinX: 0,
|
|
|
currentScanX: 0,
|
|
|
barWidth: 8,
|
|
|
gap: 2,
|
|
|
@@ -70,7 +70,7 @@ export default {
|
|
|
return last.x + 50;
|
|
|
},
|
|
|
maxDataTime() {
|
|
|
- return this.viewWindow * 3;
|
|
|
+ return this.viewWindow * 3;
|
|
|
}
|
|
|
},
|
|
|
mounted() {
|
|
|
@@ -100,18 +100,18 @@ export default {
|
|
|
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]);
|
|
|
+
|
|
|
+ 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;
|
|
|
@@ -125,7 +125,7 @@ export default {
|
|
|
|
|
|
// 【终极微调】:发车时间精确控制
|
|
|
// 正向绿波 (A -> D):10秒起步,刚好完美贴合所有绿灯边缘
|
|
|
- const gStartY = 10;
|
|
|
+ const gStartY = 10;
|
|
|
const greenBottomLine = [
|
|
|
[startX, gStartY],
|
|
|
[endX, gStartY + travelTime]
|
|
|
@@ -137,7 +137,7 @@ export default {
|
|
|
const greenCoords = [...greenBottomLine, ...[...greenTopLine].reverse()];
|
|
|
|
|
|
// 反向蓝波 (D -> A):100秒起步,完美避开 B 路口的红灯区域
|
|
|
- const bStartYAtD = 100;
|
|
|
+ const bStartYAtD = 100;
|
|
|
const blueBottomLine = [
|
|
|
[endX, bStartYAtD],
|
|
|
[startX, bStartYAtD + travelTime]
|
|
|
@@ -149,21 +149,21 @@ export default {
|
|
|
const blueCoords = [...blueBottomLine, ...[...blueTopLine].reverse()];
|
|
|
|
|
|
return [
|
|
|
- {
|
|
|
- coords: greenCoords,
|
|
|
- bottomLine: greenBottomLine,
|
|
|
+ {
|
|
|
+ coords: greenCoords,
|
|
|
+ bottomLine: greenBottomLine,
|
|
|
topLine: greenTopLine,
|
|
|
- color: this.upWaveColor,
|
|
|
- lineCol: '#2ecc71',
|
|
|
- isBlue: false
|
|
|
+ color: this.upWaveColor,
|
|
|
+ lineCol: '#2ecc71',
|
|
|
+ isBlue: false
|
|
|
},
|
|
|
- {
|
|
|
- coords: blueCoords,
|
|
|
- bottomLine: blueBottomLine,
|
|
|
+ {
|
|
|
+ coords: blueCoords,
|
|
|
+ bottomLine: blueBottomLine,
|
|
|
topLine: blueTopLine,
|
|
|
- color: this.downWaveColor,
|
|
|
- lineCol: '#3498db',
|
|
|
- isBlue: true
|
|
|
+ color: this.downWaveColor,
|
|
|
+ lineCol: '#3498db',
|
|
|
+ isBlue: true
|
|
|
}
|
|
|
];
|
|
|
},
|
|
|
@@ -175,21 +175,24 @@ export default {
|
|
|
backgroundColor: 'transparent',
|
|
|
animation: false,
|
|
|
tooltip: { show: false },
|
|
|
- grid: { left: 50, right: 100, top: 40, bottom: 80 },
|
|
|
+ grid: { left: 110, right: 50, top: 40, bottom: 50 },
|
|
|
xAxis: {
|
|
|
+ type: 'value', min: this.currentViewMinX, max: this.currentViewMinX + this.viewWindow,
|
|
|
+ interval: 20, name: '时间 (秒)',
|
|
|
+ nameLocation: 'end',
|
|
|
+ nameTextStyle: { color: '#a0aabf', padding: [0, 0, 0, 0] },
|
|
|
+ axisLine: { show: true, lineStyle: { color: 'rgba(255,255,255,0.15)' } },
|
|
|
+ axisTick: { show: false },
|
|
|
+ axisLabel: { color: '#a0aabf', fontSize: 10 },
|
|
|
+ splitLine: { show: this.showYSplitLine, lineStyle: { type: 'dashed', color: 'rgba(255, 255, 255, 0.08)' } }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
type: 'value', min: 0, max: this.maxX,
|
|
|
axisLine: { show: true, lineStyle: { color: 'rgba(255,255,255,0.15)' } },
|
|
|
axisTick: { show: false },
|
|
|
axisLabel: { show: false },
|
|
|
splitLine: { show: false }
|
|
|
},
|
|
|
- yAxis: {
|
|
|
- type: 'value', min: this.currentViewMinY, max: this.currentViewMinY + this.viewWindow,
|
|
|
- interval: 20, name: '时间 (秒)',
|
|
|
- nameTextStyle: { color: '#a0aabf', padding: [0, 30, 0, 0] },
|
|
|
- axisLabel: { color: '#a0aabf', fontSize: 10 },
|
|
|
- splitLine: { show: this.showYSplitLine, lineStyle: { type: 'dashed', color: 'rgba(255, 255, 255, 0.08)' } }
|
|
|
- },
|
|
|
series: [
|
|
|
{
|
|
|
id: 'waveSeries',
|
|
|
@@ -198,23 +201,18 @@ export default {
|
|
|
data: this.getWaveData(),
|
|
|
renderItem: (params, api) => {
|
|
|
const item = this.getWaveData()[params.dataIndex];
|
|
|
- const pixelOffsetX = item.isBlue ? (this.barWidth * 1.5 + this.gap) : (this.barWidth / 2);
|
|
|
-
|
|
|
- const mappedCoords = item.coords.map(c => {
|
|
|
- const pt = api.coord(c);
|
|
|
- return [pt[0] + pixelOffsetX, pt[1]];
|
|
|
- });
|
|
|
+ const pixelOffsetY = item.isBlue ? (this.barWidth * 1.5 + this.gap) : (this.barWidth / 2);
|
|
|
|
|
|
- const mappedBottom = item.bottomLine.map(c => {
|
|
|
- const pt = api.coord(c);
|
|
|
- return [pt[0] + pixelOffsetX, pt[1]];
|
|
|
- });
|
|
|
- const mappedTop = item.topLine.map(c => {
|
|
|
- const pt = api.coord(c);
|
|
|
- return [pt[0] + pixelOffsetX, pt[1]];
|
|
|
- });
|
|
|
+ // 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 segIdx = 0;
|
|
|
const pB1 = mappedBottom[segIdx];
|
|
|
const pB2 = mappedBottom[segIdx + 1];
|
|
|
const pT1 = mappedTop[segIdx];
|
|
|
@@ -225,12 +223,24 @@ export default {
|
|
|
angle -= Math.PI;
|
|
|
}
|
|
|
|
|
|
- const percent = 0.15;
|
|
|
+ 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 sSign = sDot > 0 ? -1 : 1;
|
|
|
+ const speedOff = 14;
|
|
|
+ const speedX = midBottomX + sSign * sPerpX * speedOff;
|
|
|
+ const speedY = midBottomY + sSign * sPerpY * speedOff;
|
|
|
+
|
|
|
return {
|
|
|
type: 'group',
|
|
|
children: [
|
|
|
@@ -254,7 +264,7 @@ export default {
|
|
|
{ type: 'polyline', shape: { points: offsetBottom }, style: { stroke: item.lineCol, lineDash: [4, 4], lineWidth: 1 } }
|
|
|
];
|
|
|
})(),
|
|
|
- { type: 'text', x: midBottomX, y: midBottomY + 12, rotation: angle, style: { text: `${this.speedKmh}km/h`, fill: '#fff', fontSize: 11, textAlign: 'center' } },
|
|
|
+ { type: 'text', x: speedX, y: speedY, rotation: angle, style: { text: `${this.speedKmh}km/h`, fill: '#fff', fontSize: 11, textAlign: 'center' } },
|
|
|
...(() => {
|
|
|
const cxBw = (midBottomX + midTopX) / 2;
|
|
|
const cyBw = (midBottomY + midTopY) / 2;
|
|
|
@@ -280,19 +290,19 @@ export default {
|
|
|
clip: true,
|
|
|
data: this.generateBarData(),
|
|
|
renderItem: (params, api) => {
|
|
|
- const xValue = api.value(0);
|
|
|
- const yStart = api.value(1);
|
|
|
- const yEnd = api.value(2);
|
|
|
+ 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([xValue, yStart]);
|
|
|
- const endP = api.coord([xValue, yEnd]);
|
|
|
- const rectX = direction === -1 ? startP[0] : (startP[0] + this.barWidth + this.gap);
|
|
|
+ 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: rectX, y: endP[1], width: this.barWidth, height: startP[1] - endP[1] },
|
|
|
+ shape: { x: startP[0], y: rectY, width: endP[0] - startP[0], height: this.barWidth },
|
|
|
style: { fill: color }
|
|
|
};
|
|
|
}
|
|
|
@@ -300,27 +310,30 @@ export default {
|
|
|
{
|
|
|
id: 'axisLabels',
|
|
|
type: 'custom',
|
|
|
- clip: false,
|
|
|
+ clip: false,
|
|
|
data: this.intersections,
|
|
|
renderItem: (params, api) => {
|
|
|
const intersection = this.intersections[params.dataIndex];
|
|
|
- const point = api.coord([intersection.x, this.currentViewMinY]);
|
|
|
- const centerXPx = point[0] + this.barWidth + this.gap / 2;
|
|
|
-
|
|
|
+ const point = api.coord([this.currentViewMinX, intersection.x]);
|
|
|
+ const centerYPx = point[1] + this.barWidth + this.gap / 2;
|
|
|
+ const labelX = point[0] - 12;
|
|
|
+
|
|
|
const children = [
|
|
|
- { type: 'text', x: centerXPx, y: point[1] + 8, style: { text: intersection.name, fill: '#fff', textAlign: 'center', fontSize: 12 } }
|
|
|
+ { type: 'text', x: labelX, y: centerYPx - 7, style: { text: intersection.name, fill: '#fff', textAlign: 'right', fontSize: 12 } }
|
|
|
];
|
|
|
if (this.showPhaseOffset) {
|
|
|
- children.push({ type: 'text', x: centerXPx, y: point[1] + 22, style: { text: `相位差: ${intersection.offsetText}`, fill: '#a0aabf', textAlign: 'center', fontSize: 11 } });
|
|
|
+ children.push({ type: 'text', x: labelX, y: centerYPx + 5, style: { text: `相位差: ${intersection.offsetText}`, fill: '#a0aabf', textAlign: 'right', fontSize: 11 } });
|
|
|
}
|
|
|
|
|
|
if (intersection.distanceNext) {
|
|
|
- const nextP = api.coord([intersection.x + intersection.distanceNext, this.currentViewMinY]);
|
|
|
- const nextCenterXPx = nextP[0] + this.barWidth + this.gap / 2;
|
|
|
- const midXPx = (centerXPx + nextCenterXPx) / 2;
|
|
|
-
|
|
|
- children.push({ type: 'line', shape: { x1: centerXPx + 30, y1: point[1] + 13, x2: nextCenterXPx - 30, y2: point[1] + 13 }, style: { stroke: 'rgba(255,255,255,0.1)', lineDash: [2, 2] } });
|
|
|
- children.push({ type: 'text', x: midXPx, y: point[1] + 13, style: { text: `${intersection.distanceNext}m`, fill: '#a0aabf', textAlign: 'center', fontSize: 11 } });
|
|
|
+ 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 - 60;
|
|
|
+ children.push({ type: 'line', shape: { x1: lineX, y1: centerYPx - 22, x2: lineX, y2: nextCenterYPx + 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: 11 } });
|
|
|
}
|
|
|
return { type: 'group', children };
|
|
|
}
|
|
|
@@ -331,13 +344,13 @@ export default {
|
|
|
clip: true,
|
|
|
data: [[this.currentScanX]],
|
|
|
renderItem: (params, api) => {
|
|
|
- const xDist = api.value(0);
|
|
|
- const top = api.coord([xDist, this.currentViewMinY + this.viewWindow]);
|
|
|
- const bottom = api.coord([xDist, this.currentViewMinY]);
|
|
|
+ const xTime = api.value(0);
|
|
|
+ const top = api.coord([xTime, 0]);
|
|
|
+ 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: 2 }
|
|
|
+ style: { stroke: this.scanLineColor, lineWidth: 4 }
|
|
|
};
|
|
|
},
|
|
|
z: 10
|
|
|
@@ -349,12 +362,12 @@ export default {
|
|
|
|
|
|
startScanLine() {
|
|
|
this.stopScanLine();
|
|
|
- this.currentScanX = this.maxX * Math.min(1, Math.max(0, this.scanLineStart));
|
|
|
- const step = this.maxX / 100;
|
|
|
+ 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.maxX) {
|
|
|
- this.currentScanX = 0;
|
|
|
+ if (this.currentScanX > this.currentViewMinX + this.viewWindow) {
|
|
|
+ this.currentScanX = this.currentViewMinX;
|
|
|
}
|
|
|
if (this.$_chart) {
|
|
|
this.$_chart.setOption({
|
|
|
@@ -374,13 +387,13 @@ export default {
|
|
|
startScroll() {
|
|
|
this.stopScroll();
|
|
|
this.scrollTimer = setInterval(() => {
|
|
|
- this.currentViewMinY += this.scrollSpeed;
|
|
|
- if (this.currentViewMinY > this.maxDataTime - this.viewWindow) {
|
|
|
- this.currentViewMinY = 0;
|
|
|
+ this.currentViewMinX += this.scrollSpeed;
|
|
|
+ if (this.currentViewMinX > this.maxDataTime - this.viewWindow) {
|
|
|
+ this.currentViewMinX = 0;
|
|
|
}
|
|
|
if (this.$_chart) {
|
|
|
this.$_chart.setOption({
|
|
|
- yAxis: { min: this.currentViewMinY, max: this.currentViewMinY + this.viewWindow }
|
|
|
+ xAxis: { min: this.currentViewMinX, max: this.currentViewMinX + this.viewWindow }
|
|
|
});
|
|
|
}
|
|
|
}, 16);
|
|
|
@@ -403,4 +416,4 @@ export default {
|
|
|
min-height: 0;
|
|
|
flex: 1;
|
|
|
}
|
|
|
-</style>
|
|
|
+</style>
|