|
|
@@ -21,7 +21,8 @@ export default {
|
|
|
{ name: '交叉口D', distanceNext: 0, offset: 0 }
|
|
|
]
|
|
|
},
|
|
|
- speedKmh: { type: Number, default: 38.9 }, // 设计车速
|
|
|
+ speedKmh: { type: Number, default: 38.9 }, // 正向设计车速
|
|
|
+ speedKmhBackward: { type: Number, default: null }, // 反向设计车速;null 时用 speedKmh
|
|
|
cycle: { type: Number, default: 100 }, // 红绿灯周期
|
|
|
greenDuration: { type: Number, default: 40 }, // 绿灯时长
|
|
|
bandwidth: { type: Number, default: 31.5 }, // 波带宽(秒)
|
|
|
@@ -45,11 +46,17 @@ export default {
|
|
|
scanLineTimer: null,
|
|
|
currentViewMinX: 0,
|
|
|
currentScanX: 0,
|
|
|
- barWidth: 8,
|
|
|
- gap: 2,
|
|
|
};
|
|
|
},
|
|
|
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) => {
|
|
|
@@ -92,9 +99,16 @@ export default {
|
|
|
watch: {
|
|
|
roadSegments: { handler() { this.updateChart(); }, deep: true },
|
|
|
speedKmh() { this.updateChart(); },
|
|
|
+ speedKmhBackward() { 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);
|
|
|
@@ -122,33 +136,36 @@ export default {
|
|
|
},
|
|
|
|
|
|
getWaveData() {
|
|
|
- const speedMs = this.speedKmh / 3.6; // 换算为 m/s
|
|
|
+ const greenSpeed = this.speedKmh;
|
|
|
+ const blueSpeed = this.speedKmhBackward != null ? this.speedKmhBackward : this.speedKmh;
|
|
|
+ const greenMs = greenSpeed / 3.6;
|
|
|
+ const blueMs = blueSpeed / 3.6;
|
|
|
const startX = this.intersections[0].x;
|
|
|
const endX = this.intersections[this.intersections.length - 1].x;
|
|
|
- const travelTime = (endX - startX) / speedMs;
|
|
|
+ const greenTravelTime = (endX - startX) / greenMs;
|
|
|
+ const blueTravelTime = (endX - startX) / blueMs;
|
|
|
|
|
|
- // 【终极微调】:发车时间精确控制
|
|
|
- // 正向绿波 (A -> D):10秒起步,刚好完美贴合所有绿灯边缘
|
|
|
- const gStartY = 10;
|
|
|
+ // 正向绿波 (A -> D):gStartY 秒起步
|
|
|
+ const gStartY = 0;
|
|
|
const greenBottomLine = [
|
|
|
[startX, gStartY],
|
|
|
- [endX, gStartY + travelTime]
|
|
|
+ [endX, gStartY + greenTravelTime]
|
|
|
];
|
|
|
const greenTopLine = [
|
|
|
[startX, gStartY + this.bandwidth],
|
|
|
- [endX, gStartY + travelTime + this.bandwidth]
|
|
|
+ [endX, gStartY + greenTravelTime + this.bandwidth]
|
|
|
];
|
|
|
const greenCoords = [...greenBottomLine, ...[...greenTopLine].reverse()];
|
|
|
|
|
|
- // 反向蓝波 (D -> A):100秒起步,完美避开 B 路口的红灯区域
|
|
|
+ // 反向蓝波 (D -> A):bStartYAtD 秒起步
|
|
|
const bStartYAtD = 100;
|
|
|
const blueBottomLine = [
|
|
|
[endX, bStartYAtD],
|
|
|
- [startX, bStartYAtD + travelTime]
|
|
|
+ [startX, bStartYAtD + blueTravelTime]
|
|
|
];
|
|
|
const blueTopLine = [
|
|
|
[endX, bStartYAtD + this.bandwidth],
|
|
|
- [startX, bStartYAtD + travelTime + this.bandwidth]
|
|
|
+ [startX, bStartYAtD + blueTravelTime + this.bandwidth]
|
|
|
];
|
|
|
const blueCoords = [...blueBottomLine, ...[...blueTopLine].reverse()];
|
|
|
|
|
|
@@ -159,7 +176,8 @@ export default {
|
|
|
topLine: greenTopLine,
|
|
|
color: this.upWaveColor,
|
|
|
lineCol: '#2ecc71',
|
|
|
- isBlue: false
|
|
|
+ isBlue: false,
|
|
|
+ speed: greenSpeed
|
|
|
},
|
|
|
{
|
|
|
coords: blueCoords,
|
|
|
@@ -167,7 +185,8 @@ export default {
|
|
|
topLine: blueTopLine,
|
|
|
color: this.downWaveColor,
|
|
|
lineCol: '#3498db',
|
|
|
- isBlue: true
|
|
|
+ isBlue: true,
|
|
|
+ speed: blueSpeed
|
|
|
}
|
|
|
];
|
|
|
},
|
|
|
@@ -175,19 +194,38 @@ export default {
|
|
|
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: 140, right: 40, top: 40, bottom: 50 },
|
|
|
+ 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: 20, name: '时间 (秒)',
|
|
|
+ interval: tickInterval, name: '时间 (秒)',
|
|
|
nameLocation: 'end',
|
|
|
- nameTextStyle: { color: '#a0aabf', padding: [0, 0, 0, 0] },
|
|
|
- axisLine: { show: true, onZero: false, lineStyle: { color: 'rgba(255,255,255,0.15)' } },
|
|
|
- axisTick: { show: false },
|
|
|
- axisLabel: { color: '#a0aabf', fontSize: 10 },
|
|
|
+ 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: {
|
|
|
@@ -222,10 +260,10 @@ export default {
|
|
|
const pT1 = mappedTop[segIdx];
|
|
|
const pT2 = mappedTop[segIdx + 1];
|
|
|
|
|
|
- let angle = Math.atan2(pB2[1] - pB1[1], pB2[0] - pB1[0]);
|
|
|
- if (item.isBlue) {
|
|
|
- angle -= Math.PI;
|
|
|
- }
|
|
|
+ // 波带角度统一用底线方向(atan2),保证文字沿着波带方向读
|
|
|
+ const angle = Math.atan2(pB2[1] - pB1[1], pB2[0] - pB1[0]);
|
|
|
+ // 速度文字保持水平(与 x 轴平行)
|
|
|
+ const speedAngle = 0;
|
|
|
|
|
|
const percent = 0.15;
|
|
|
const midBottomX = pB1[0] + (pB2[0] - pB1[0]) * percent;
|
|
|
@@ -233,25 +271,34 @@ export default {
|
|
|
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;
|
|
|
+ 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 = 4;
|
|
|
+ // 虚线沿垂直于波带方向向外偏移:绿波走顶线外侧,蓝波走底线外侧(互相镜像)
|
|
|
+ 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;
|
|
|
@@ -262,13 +309,22 @@ export default {
|
|
|
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;
|
|
|
- const sign = dot > 0 ? -1 : 1;
|
|
|
- const offsetBottom = mappedBottom.map(p => [p[0] + sign * perpX * lineGap, p[1] + sign * perpY * lineGap]);
|
|
|
+
|
|
|
+ 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: offsetBottom }, style: { stroke: item.lineCol, lineDash: [4, 4], lineWidth: 1 } }
|
|
|
+ { type: 'polyline', shape: { points }, style: { stroke: item.lineCol, lineDash: [4, 4], lineWidth: 1 } }
|
|
|
];
|
|
|
})(),
|
|
|
- { type: 'text', x: speedX, y: speedY, rotation: angle, style: { text: `${this.speedKmh}km/h`, fill: '#fff', fontSize: 11, textAlign: 'center' } },
|
|
|
+ { type: 'text', x: speedX, y: speedY, rotation: speedAngle, style: { text: `${item.speed}km/h`, fill: '#fff', fontSize: this.fs(11), textAlign: 'center' } },
|
|
|
...(() => {
|
|
|
const cxBw = (midBottomX + midTopX) / 2;
|
|
|
const cyBw = (midBottomY + midTopY) / 2;
|
|
|
@@ -277,10 +333,10 @@ export default {
|
|
|
const lenBw = Math.sqrt(dxBw * dxBw + dyBw * dyBw) || 1;
|
|
|
const uxBw = dxBw / lenBw;
|
|
|
const uyBw = dyBw / lenBw;
|
|
|
- const gapBw = 10;
|
|
|
+ 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: 11, textAlign: 'center', textVerticalAlign: 'middle' } },
|
|
|
+ { type: 'text', x: cxBw, y: cyBw, rotation: 0, 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)' } }
|
|
|
];
|
|
|
})()
|
|
|
@@ -320,7 +376,9 @@ export default {
|
|
|
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] - 12;
|
|
|
+ const labelX = point[0] - this.fs(12);
|
|
|
+ const nameFs = this.fs(12);
|
|
|
+ const subFs = this.fs(11);
|
|
|
|
|
|
// 名称较长时按 "-" 拆成两行显示
|
|
|
const dashIdx = intersection.name.indexOf('-');
|
|
|
@@ -329,14 +387,14 @@ export default {
|
|
|
|
|
|
const children = [];
|
|
|
if (nameLine2) {
|
|
|
- children.push({ type: 'text', x: labelX, y: centerYPx - 15, style: { text: nameLine1, fill: '#fff', textAlign: 'right', fontSize: 12 } });
|
|
|
- children.push({ type: 'text', x: labelX, y: centerYPx - 1, style: { text: nameLine2, fill: '#fff', textAlign: 'right', fontSize: 12 } });
|
|
|
+ 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 - 7, style: { text: nameLine1, fill: '#fff', textAlign: 'right', fontSize: 12 } });
|
|
|
+ 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 + 14 : centerYPx + 5;
|
|
|
- children.push({ type: 'text', x: labelX, y: offYPx, style: { text: `相位差: ${intersection.offsetText}`, fill: '#a0aabf', textAlign: 'right', fontSize: 11 } });
|
|
|
+ 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) {
|
|
|
@@ -345,9 +403,9 @@ export default {
|
|
|
const midYPx = (centerYPx + nextCenterYPx) / 2;
|
|
|
|
|
|
// 非反转 yAxis:距离越大像素 y 越小,nextCenterYPx < centerYPx
|
|
|
- const lineX = labelX - 85;
|
|
|
- children.push({ type: 'line', shape: { x1: lineX, y1: centerYPx - 36, 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 } });
|
|
|
+ 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 };
|
|
|
}
|
|
|
@@ -364,7 +422,7 @@ export default {
|
|
|
return {
|
|
|
type: 'line',
|
|
|
shape: { x1: top[0], y1: top[1], x2: bottom[0], y2: bottom[1] },
|
|
|
- style: { stroke: this.scanLineColor, lineWidth: 4 }
|
|
|
+ style: { stroke: this.scanLineColor, lineWidth: this.fs(4) }
|
|
|
};
|
|
|
},
|
|
|
z: 10
|