|
|
@@ -1,357 +1,386 @@
|
|
|
<template>
|
|
|
- <div ref="chartContainer" class="traffic-timespace-chart"></div>
|
|
|
+ <div ref="chartRef" class="traffic-timespace-chart"></div>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
import * as echarts from 'echarts';
|
|
|
-// 1. 引入我们的防变形神器和混入
|
|
|
import echartsResize, { px2echarts } from '@/mixins/echartsResize.js';
|
|
|
|
|
|
export default {
|
|
|
name: 'TrafficTimeSpace',
|
|
|
mixins: [echartsResize],
|
|
|
props: {
|
|
|
- intersections: { type: Array, required: true },
|
|
|
- distances: { type: Array, required: true },
|
|
|
- waveData: { type: Array, default: () => [] },
|
|
|
- greenData: { type: Array, default: () => [] },
|
|
|
- viewWindow: { type: Number, default: 350 },
|
|
|
- autoScroll: { type: Boolean, default: false },
|
|
|
- scrollSpeed: { type: Number, default: 0.5 },
|
|
|
- upWaveColor: { type: String, default: 'rgba(46, 196, 182, 0.45)' },
|
|
|
- downWaveColor: { type: String, default: 'rgba(104, 231, 95, 0.4)' },
|
|
|
- waveLabelColor: { type: String, default: '#e0f7fa' },
|
|
|
- // 【新增】扫描线颜色配置
|
|
|
- timeLineColor: { type: String, default: '#1a9bff' }
|
|
|
+ // 【终极修复】:使用 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(255, 60, 60, 0.8)' },
|
|
|
+ scanLineStart: { type: Number, default: 0 }
|
|
|
},
|
|
|
data() {
|
|
|
return {
|
|
|
+ $_chart: null,
|
|
|
scrollTimer: null,
|
|
|
- currentViewTime: 0,
|
|
|
- timeLineTimer: null,
|
|
|
- currentTimeIndicator: Math.random() * this.viewWindow,
|
|
|
- // 每条绿波带的速度(初始固定,扫描线经过后定格)
|
|
|
- waveSpeeds: [],
|
|
|
- // 当前扫描线命中的所有绿波带索引
|
|
|
- activeWaveIndices: []
|
|
|
+ scanLineTimer: null,
|
|
|
+ currentViewMinY: 0,
|
|
|
+ currentScanX: 0,
|
|
|
+ barWidth: 8,
|
|
|
+ gap: 2,
|
|
|
};
|
|
|
},
|
|
|
computed: {
|
|
|
- maxDistance() { return Math.max(...this.distances, 100); },
|
|
|
- maxDataTime() {
|
|
|
- let max = 0;
|
|
|
- this.waveData.forEach(w => max = Math.max(max, w.xBR, w.xTR));
|
|
|
- this.greenData.forEach(g => max = Math.max(max, g.end));
|
|
|
- return max;
|
|
|
- },
|
|
|
- echartsWaveData() {
|
|
|
- return this.waveData.map((w, i) => {
|
|
|
- const spd = this.waveSpeeds[i] || 50;
|
|
|
- return [w.yBottom, w.yTop, w.xBL, w.xBR, w.xTL, w.xTR, spd + 'km/h', w.direction || 'up'];
|
|
|
+ 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;
|
|
|
});
|
|
|
},
|
|
|
- echartsGreenData() { return this.greenData.map(g => [g.y, g.start, g.end]); },
|
|
|
- echartsRedData() { return this.distances.map(y => [y]); },
|
|
|
- reversedIntersections() { return [...this.intersections].reverse(); }
|
|
|
+ maxX() {
|
|
|
+ const last = this.intersections[this.intersections.length - 1];
|
|
|
+ return last.x + 50;
|
|
|
+ },
|
|
|
+ maxDataTime() {
|
|
|
+ return this.viewWindow * 3;
|
|
|
+ }
|
|
|
},
|
|
|
mounted() {
|
|
|
this.$nextTick(() => {
|
|
|
this.initChart();
|
|
|
if (this.autoScroll) this.startScroll();
|
|
|
- // 【新增】组件挂载后启动扫描线
|
|
|
- this.startTimeLine();
|
|
|
+ if (this.showScanLine) this.startScanLine();
|
|
|
});
|
|
|
},
|
|
|
beforeDestroy() {
|
|
|
this.stopScroll();
|
|
|
- // 【新增】销毁时清理定时器
|
|
|
- this.stopTimeLine();
|
|
|
+ this.stopScanLine();
|
|
|
+ if (this.$_chart) this.$_chart.dispose();
|
|
|
},
|
|
|
watch: {
|
|
|
- waveData() {
|
|
|
- this.initWaveSpeeds();
|
|
|
- this.updateChart();
|
|
|
- },
|
|
|
- greenData() { this.updateChart(); },
|
|
|
+ roadSegments: { handler() { this.updateChart(); }, deep: true },
|
|
|
+ speedKmh() { this.updateChart(); },
|
|
|
autoScroll(val) { val ? this.startScroll() : this.stopScroll(); }
|
|
|
},
|
|
|
methods: {
|
|
|
- // 为每条绿波带生成初始固定速度
|
|
|
- initWaveSpeeds() {
|
|
|
- this.waveSpeeds = this.waveData.map(w => w.speed || (45 + Math.round(Math.random() * 10)));
|
|
|
- },
|
|
|
-
|
|
|
initChart() {
|
|
|
- this.initWaveSpeeds();
|
|
|
- this.$_chart = echarts.init(this.$refs.chartContainer);
|
|
|
+ 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):10秒起步,刚好完美贴合所有绿灯边缘
|
|
|
+ const gStartY = 10;
|
|
|
+ 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):100秒起步,完美避开 B 路口的红灯区域
|
|
|
+ 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;
|
|
|
|
|
|
- const self = this;
|
|
|
- const distances = this.distances;
|
|
|
- const intersections = this.reversedIntersections;
|
|
|
- const maxDist = this.maxDistance;
|
|
|
- const step = distances.length > 1 ? distances[1] - distances[0] : 300;
|
|
|
-
|
|
|
- this.$_chart.setOption({
|
|
|
+ const option = {
|
|
|
backgroundColor: 'transparent',
|
|
|
animation: false,
|
|
|
tooltip: { show: false },
|
|
|
- grid: {
|
|
|
- left: px2echarts(80),
|
|
|
- right: px2echarts(15),
|
|
|
- top: px2echarts(10),
|
|
|
- bottom: px2echarts(25)
|
|
|
- },
|
|
|
+ grid: { left: 50, right: 100, top: 40, bottom: 80 },
|
|
|
xAxis: {
|
|
|
- type: 'value',
|
|
|
- min: this.currentViewTime,
|
|
|
- max: this.currentViewTime + this.viewWindow,
|
|
|
- axisLabel: {
|
|
|
- color: '#7b95b9',
|
|
|
- formatter: '{value}s',
|
|
|
- fontSize: px2echarts(10)
|
|
|
- },
|
|
|
- splitLine: { show: true, lineStyle: { color: '#1a305d', type: 'solid' } },
|
|
|
- axisLine: { lineStyle: { color: '#31548e' } }
|
|
|
+ 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: 0,
|
|
|
- max: maxDist,
|
|
|
- interval: step,
|
|
|
- axisLabel: {
|
|
|
- interval: 0,
|
|
|
- color: '#9cb1d4',
|
|
|
- fontWeight: 'bold',
|
|
|
- fontSize: px2echarts(10),
|
|
|
- formatter: value => distances.includes(value) ? intersections[distances.indexOf(value)] : ''
|
|
|
- },
|
|
|
- splitLine: { show: true, lineStyle: { color: '#1a305d' } }
|
|
|
+ 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)' } }
|
|
|
},
|
|
|
- // 【修改】给每个系列加上 id,新增时间线系列
|
|
|
series: [
|
|
|
{
|
|
|
id: 'waveSeries',
|
|
|
type: 'custom',
|
|
|
- renderItem: function (params, api) { return self.renderWave(params, api); },
|
|
|
- data: this.echartsWaveData,
|
|
|
clip: true,
|
|
|
- z: 1
|
|
|
+ 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 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]];
|
|
|
+ });
|
|
|
+
|
|
|
+ const segIdx = 0;
|
|
|
+ const pB1 = mappedBottom[segIdx];
|
|
|
+ const pB2 = mappedBottom[segIdx + 1];
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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;
|
|
|
+
|
|
|
+ return {
|
|
|
+ type: 'group',
|
|
|
+ children: [
|
|
|
+ { type: 'polygon', shape: { points: mappedCoords }, style: { fill: item.color } },
|
|
|
+ ...(() => {
|
|
|
+ // 虚线与绿波带之间留间隙,沿垂直于底线方向向外偏移
|
|
|
+ const lineGap = 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;
|
|
|
+ const sign = dot > 0 ? -1 : 1;
|
|
|
+ const offsetBottom = mappedBottom.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: 'text', x: midBottomX, y: midBottomY + 12, rotation: angle, style: { text: `${this.speedKmh}km/h`, fill: '#fff', fontSize: 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 = 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: 'line', shape: { x1: cxBw + uxBw * gapBw, y1: cyBw + uyBw * gapBw, x2: midTopX, y2: midTopY }, style: { stroke: 'rgba(255, 255, 255, 0.4)' } }
|
|
|
+ ];
|
|
|
+ })()
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ }
|
|
|
},
|
|
|
{
|
|
|
- id: 'redSeries',
|
|
|
+ id: 'lightSeries',
|
|
|
type: 'custom',
|
|
|
- renderItem: function (params, api) { return self.renderRedBackground(params, api); },
|
|
|
- data: this.echartsRedData,
|
|
|
clip: true,
|
|
|
- z: 2
|
|
|
+ data: this.generateBarData(),
|
|
|
+ renderItem: (params, api) => {
|
|
|
+ const xValue = api.value(0);
|
|
|
+ const yStart = api.value(1);
|
|
|
+ const yEnd = 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);
|
|
|
+
|
|
|
+ return {
|
|
|
+ type: 'rect',
|
|
|
+ shape: { x: rectX, y: endP[1], width: this.barWidth, height: startP[1] - endP[1] },
|
|
|
+ style: { fill: color }
|
|
|
+ };
|
|
|
+ }
|
|
|
},
|
|
|
{
|
|
|
- id: 'greenSeries',
|
|
|
+ id: 'axisLabels',
|
|
|
type: 'custom',
|
|
|
- renderItem: function (params, api) { return self.renderGreenLight(params, api); },
|
|
|
- data: this.echartsGreenData,
|
|
|
- clip: true,
|
|
|
- z: 3
|
|
|
+ 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 children = [
|
|
|
+ { type: 'text', x: centerXPx, y: point[1] + 8, style: { text: intersection.name, fill: '#fff', textAlign: 'center', 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 } });
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 } });
|
|
|
+ }
|
|
|
+ return { type: 'group', children };
|
|
|
+ }
|
|
|
},
|
|
|
- {
|
|
|
- id: 'timeLineSeries', // 【新增】垂直扫描线系列
|
|
|
+ ...(this.showScanLine ? [{
|
|
|
+ id: 'scanLineSeries',
|
|
|
type: 'custom',
|
|
|
- renderItem: function (params, api) { return self.renderTimeLine(params, api); },
|
|
|
- data: [[this.currentTimeIndicator]], // 初始数据
|
|
|
clip: true,
|
|
|
- z: 10 // 放在最上层
|
|
|
- }
|
|
|
- ]
|
|
|
- });
|
|
|
- },
|
|
|
-
|
|
|
- renderRedBackground(params, api) {
|
|
|
- const y = api.value(0);
|
|
|
- const startX = api.coord([0, y])[0];
|
|
|
- const endX = api.coord([this.maxDataTime || this.viewWindow, y])[0];
|
|
|
- return {
|
|
|
- type: 'rect',
|
|
|
- shape: {
|
|
|
- x: startX,
|
|
|
- y: api.coord([0, y])[1] - px2echarts(2),
|
|
|
- width: endX - startX,
|
|
|
- height: px2echarts(4)
|
|
|
- },
|
|
|
- style: { fill: '#f02828' }
|
|
|
- };
|
|
|
- },
|
|
|
-
|
|
|
- renderGreenLight(params, api) {
|
|
|
- const y = api.value(0);
|
|
|
- const p1 = api.coord([api.value(1), y]);
|
|
|
- const p2 = api.coord([api.value(2), y]);
|
|
|
- return {
|
|
|
- type: 'rect',
|
|
|
- shape: {
|
|
|
- x: p1[0],
|
|
|
- y: p1[1] - px2echarts(3),
|
|
|
- width: p2[0] - p1[0],
|
|
|
- height: px2echarts(6)
|
|
|
- },
|
|
|
- style: api.style({ fill: '#68e75f' })
|
|
|
- };
|
|
|
- },
|
|
|
-
|
|
|
- renderWave(params, api) {
|
|
|
- const yBottom = api.value(0), yTop = api.value(1);
|
|
|
- const xBL = api.value(2), xBR = api.value(3), xTL = api.value(4), xTR = api.value(5);
|
|
|
- const text = api.value(6), dir = api.value(7);
|
|
|
-
|
|
|
- const ptBL = api.coord([xBL, yBottom]), ptBR = api.coord([xBR, yBottom]);
|
|
|
- const ptTL = api.coord([xTL, yTop]), ptTR = api.coord([xTR, yTop]);
|
|
|
- const angle = -Math.atan2(ptTL[1] - ptBL[1], ptTL[0] - ptBL[0]);
|
|
|
- const fillColor = dir === 'up' ? this.upWaveColor : this.downWaveColor;
|
|
|
-
|
|
|
- return {
|
|
|
- type: 'group',
|
|
|
- children: [
|
|
|
- {
|
|
|
- type: 'polygon',
|
|
|
- shape: { points: [ptBL, ptBR, ptTR, ptTL] },
|
|
|
- z2: 1,
|
|
|
- style: api.style({ fill: fillColor, stroke: 'none' })
|
|
|
- },
|
|
|
- {
|
|
|
- type: 'text',
|
|
|
- x: (ptBL[0] + ptTR[0]) / 2,
|
|
|
- y: (ptBL[1] + ptTR[1]) / 2,
|
|
|
- rotation: angle,
|
|
|
- z2: 5,
|
|
|
- style: {
|
|
|
- text: text,
|
|
|
- fill: this.waveLabelColor,
|
|
|
- font: `bold ${px2echarts(12)}px sans-serif`,
|
|
|
- textAlign: 'center',
|
|
|
- textVerticalAlign: 'middle'
|
|
|
- }
|
|
|
- }
|
|
|
+ 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]);
|
|
|
+ return {
|
|
|
+ type: 'line',
|
|
|
+ shape: { x1: top[0], y1: top[1], x2: bottom[0], y2: bottom[1] },
|
|
|
+ style: { stroke: this.scanLineColor, lineWidth: 2 }
|
|
|
+ };
|
|
|
+ },
|
|
|
+ z: 10
|
|
|
+ }] : [])
|
|
|
]
|
|
|
};
|
|
|
+ this.$_chart.setOption(option, { replaceMerge: ['series'] });
|
|
|
},
|
|
|
|
|
|
- // 【新增】渲染垂直扫描线
|
|
|
- renderTimeLine(params, api) {
|
|
|
- const xVal = api.value(0);
|
|
|
- const start = api.coord([xVal, 0]); // 底部坐标
|
|
|
- const end = api.coord([xVal, this.maxDistance]); // 顶部坐标
|
|
|
-
|
|
|
- return {
|
|
|
- type: 'line',
|
|
|
- shape: {
|
|
|
- x1: start[0],
|
|
|
- y1: start[1],
|
|
|
- x2: end[0],
|
|
|
- y2: end[1]
|
|
|
- },
|
|
|
- style: {
|
|
|
- stroke: this.timeLineColor, // 线条颜色
|
|
|
- lineWidth: px2echarts(5), // 线条粗细
|
|
|
- lineDash: null // 实线
|
|
|
+ startScanLine() {
|
|
|
+ this.stopScanLine();
|
|
|
+ this.currentScanX = this.maxX * Math.min(1, Math.max(0, this.scanLineStart));
|
|
|
+ const step = this.maxX / 100;
|
|
|
+ this.scanLineTimer = setInterval(() => {
|
|
|
+ this.currentScanX += step;
|
|
|
+ if (this.currentScanX > this.maxX) {
|
|
|
+ this.currentScanX = 0;
|
|
|
}
|
|
|
- };
|
|
|
- },
|
|
|
-
|
|
|
- // 启动扫描线动画(在 viewWindow 内循环,经过绿波带时速度缓慢波动)
|
|
|
- startTimeLine() {
|
|
|
- this.stopTimeLine();
|
|
|
- let lastTime = Date.now();
|
|
|
- let elapsed = 0;
|
|
|
- // 控制速度变化节奏:每隔一段时间才变一次
|
|
|
- let speedChangeTimer = 0;
|
|
|
-
|
|
|
- this.timeLineTimer = setInterval(() => {
|
|
|
- const now = Date.now();
|
|
|
- const delta = (now - lastTime) / 1000;
|
|
|
- lastTime = now;
|
|
|
- elapsed += delta;
|
|
|
- speedChangeTimer += delta;
|
|
|
-
|
|
|
- this.currentTimeIndicator += 1;
|
|
|
-
|
|
|
- // 在 viewWindow 范围内循环,循环时重置所有速度
|
|
|
- if (this.currentTimeIndicator > this.viewWindow) {
|
|
|
- this.currentTimeIndicator = 0;
|
|
|
- this.initWaveSpeeds();
|
|
|
- }
|
|
|
-
|
|
|
- // 检测扫描线命中的所有绿波带(支持重叠)
|
|
|
- const x = this.currentTimeIndicator;
|
|
|
- const hitIndices = [];
|
|
|
- for (let i = 0; i < this.waveData.length; i++) {
|
|
|
- const w = this.waveData[i];
|
|
|
- const xMin = Math.min(w.xBL, w.xTL);
|
|
|
- const xMax = Math.max(w.xBR, w.xTR);
|
|
|
- if (x >= xMin && x <= xMax) {
|
|
|
- hitIndices.push(i);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const prevIndices = this.activeWaveIndices;
|
|
|
- this.activeWaveIndices = hitIndices;
|
|
|
-
|
|
|
- // 扫描线在绿波带内时,每0.8秒缓慢变化所有命中带的速度
|
|
|
- let needFullUpdate = false;
|
|
|
- if (hitIndices.length > 0 && speedChangeTimer >= 0.8) {
|
|
|
- speedChangeTimer = 0;
|
|
|
- hitIndices.forEach(idx => {
|
|
|
- const cur = this.waveSpeeds[idx] || 50;
|
|
|
- const change = Math.round((Math.random() - 0.5) * 4);
|
|
|
- this.$set(this.waveSpeeds, idx, Math.max(45, Math.min(55, cur + change)));
|
|
|
- });
|
|
|
- needFullUpdate = true;
|
|
|
- }
|
|
|
-
|
|
|
- // 命中集合变化时也需要刷新
|
|
|
- if (hitIndices.length !== prevIndices.length ||
|
|
|
- hitIndices.some((v, i) => v !== prevIndices[i])) {
|
|
|
- needFullUpdate = true;
|
|
|
- }
|
|
|
-
|
|
|
if (this.$_chart) {
|
|
|
- if (needFullUpdate) {
|
|
|
- this.$_chart.setOption({
|
|
|
- series: [
|
|
|
- { id: 'waveSeries', data: this.echartsWaveData },
|
|
|
- { id: 'timeLineSeries', data: [[this.currentTimeIndicator]] }
|
|
|
- ]
|
|
|
- });
|
|
|
- } else {
|
|
|
- this.$_chart.setOption({
|
|
|
- series: [{ id: 'timeLineSeries', data: [[this.currentTimeIndicator]] }]
|
|
|
- });
|
|
|
- }
|
|
|
+ this.$_chart.setOption({
|
|
|
+ series: [{ id: 'scanLineSeries', data: [[this.currentScanX]] }]
|
|
|
+ });
|
|
|
}
|
|
|
}, 1000);
|
|
|
},
|
|
|
|
|
|
- // 【新增】停止扫描线动画
|
|
|
- stopTimeLine() {
|
|
|
- if (this.timeLineTimer) {
|
|
|
- clearInterval(this.timeLineTimer);
|
|
|
- this.timeLineTimer = null;
|
|
|
+ stopScanLine() {
|
|
|
+ if (this.scanLineTimer) {
|
|
|
+ clearInterval(this.scanLineTimer);
|
|
|
+ this.scanLineTimer = null;
|
|
|
}
|
|
|
},
|
|
|
|
|
|
startScroll() {
|
|
|
this.stopScroll();
|
|
|
this.scrollTimer = setInterval(() => {
|
|
|
- this.currentViewTime += this.scrollSpeed;
|
|
|
- if (this.currentViewTime > this.maxDataTime - this.viewWindow) {
|
|
|
- this.currentViewTime = 0;
|
|
|
+ this.currentViewMinY += this.scrollSpeed;
|
|
|
+ if (this.currentViewMinY > this.maxDataTime - this.viewWindow) {
|
|
|
+ this.currentViewMinY = 0;
|
|
|
}
|
|
|
if (this.$_chart) {
|
|
|
this.$_chart.setOption({
|
|
|
- xAxis: { min: this.currentViewTime, max: this.currentViewTime + this.viewWindow }
|
|
|
+ yAxis: { min: this.currentViewMinY, max: this.currentViewMinY + this.viewWindow }
|
|
|
});
|
|
|
}
|
|
|
}, 16);
|
|
|
@@ -369,8 +398,9 @@ export default {
|
|
|
|
|
|
<style scoped>
|
|
|
.traffic-timespace-chart {
|
|
|
- flex: 1;
|
|
|
width: 100%;
|
|
|
+ height: 100%;
|
|
|
min-height: 0;
|
|
|
+ flex: 1;
|
|
|
}
|
|
|
</style>
|