|
|
@@ -92,20 +92,44 @@ export default {
|
|
|
name: 'SignalTimingChart',
|
|
|
mixins: [echartsResize],
|
|
|
props: {
|
|
|
- cycleLength: { type: Number, default: 140 },
|
|
|
+ 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 }
|
|
|
+ autoScan: { type: Boolean, default: false },
|
|
|
+ // 只渲染已运行的部分(生长条模式):endTime > activeTime 的相位裁断到 activeTime,
|
|
|
+ // 之后的相位整段不渲染。配合 showScanLine=false 用于"实时方案"图。
|
|
|
+ clipToActive: { type: Boolean, default: false },
|
|
|
+ // 紧凑型扫描线 + badge:用于双相位图等多阶段/小行高场景,badge 尺寸跟随容器自适应、grid 留白动态收紧
|
|
|
+ // 默认 false,单图 4 阶段保持原配色与尺寸,不破坏既有视觉
|
|
|
+ compactScanLine: { type: Boolean, default: false }
|
|
|
},
|
|
|
data() {
|
|
|
- return { scaleFactor: 1, internalTime: 0 };
|
|
|
+ return { scaleFactor: 1, vScaleFactor: 1, internalTime: 0 };
|
|
|
},
|
|
|
computed: {
|
|
|
activeTime() {
|
|
|
return this.autoScan ? this.internalTime : this.currentTime;
|
|
|
+ },
|
|
|
+ effectivePhaseData() {
|
|
|
+ if (!this.clipToActive) return this.phaseData;
|
|
|
+ const t = this.activeTime;
|
|
|
+ if (t <= 0) return [];
|
|
|
+ const out = [];
|
|
|
+ for (const item of this.phaseData) {
|
|
|
+ const start = item[1];
|
|
|
+ const end = item[2];
|
|
|
+ if (start >= t) continue; // 整段未开始
|
|
|
+ if (end <= t) { out.push(item); continue; } // 整段已完成
|
|
|
+ // 跨界裁断:复制一份,把 endTime/duration 截到 t
|
|
|
+ const clipped = item.slice();
|
|
|
+ clipped[2] = t;
|
|
|
+ clipped[4] = t - start;
|
|
|
+ out.push(clipped);
|
|
|
+ }
|
|
|
+ return out;
|
|
|
}
|
|
|
},
|
|
|
mounted() {
|
|
|
@@ -117,9 +141,10 @@ export default {
|
|
|
this.stopAutoScan();
|
|
|
},
|
|
|
watch: {
|
|
|
- currentTime(val) {
|
|
|
- if (!this.autoScan) {
|
|
|
- if (this.$_chart) this.updateScanLine();
|
|
|
+ currentTime() {
|
|
|
+ if (!this.autoScan && this.$_chart) {
|
|
|
+ if (this.clipToActive) this.updateChart();
|
|
|
+ else this.updateScanLine();
|
|
|
}
|
|
|
},
|
|
|
autoScan(val) {
|
|
|
@@ -138,6 +163,47 @@ export default {
|
|
|
const el = this.$el;
|
|
|
if (!el) return;
|
|
|
this.scaleFactor = Math.max(0.5, el.clientWidth / 600);
|
|
|
+ // 垂直 scale:以 80px 容器高度为 1x 基准;矮容器拉小,高容器轻微放大
|
|
|
+ // 给紧凑模式 markLine label 用,避免窄行 chart 中 badge 视觉过重
|
|
|
+ this.vScaleFactor = Math.max(0.4, Math.min(2, el.clientHeight / 80));
|
|
|
+ },
|
|
|
+ // 扫描线 label 配置:紧凑模式跟随 min(横向, 纵向) scale 自适应,否则保持原始(10px font, [4,8] padding)
|
|
|
+ _scanLabel(realMaxTime) {
|
|
|
+ const s = this.scaleFactor;
|
|
|
+ const base = {
|
|
|
+ show: this.showScanLineLabel,
|
|
|
+ position: 'start',
|
|
|
+ formatter: `${Math.round(this.activeTime)}/${realMaxTime}`,
|
|
|
+ color: '#fff',
|
|
|
+ backgroundColor: COLORS.MARK_BLUE,
|
|
|
+ };
|
|
|
+ if (this.compactScanLine) {
|
|
|
+ const cs = Math.min(s, this.vScaleFactor);
|
|
|
+ const fs = Math.max(8, Math.round(10 * cs));
|
|
|
+ return {
|
|
|
+ ...base,
|
|
|
+ padding: [Math.max(1, Math.round(1 * cs)), Math.max(2, Math.round(3 * cs))],
|
|
|
+ borderRadius: 2,
|
|
|
+ fontSize: fs,
|
|
|
+ lineHeight: fs,
|
|
|
+ // bottom 对齐:badge 整个坐落在 grid.top 预留区里,不会被 canvas 顶边裁
|
|
|
+ // offset y=2:在 bottom 锚点基础上整体下移 2px,与下方 chart 留出视觉间距
|
|
|
+ verticalAlign: 'bottom',
|
|
|
+ offset: [0, 2],
|
|
|
+ };
|
|
|
+ }
|
|
|
+ // 原始(4 阶段单图)样式:保持向后兼容
|
|
|
+ return {
|
|
|
+ ...base,
|
|
|
+ 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)],
|
|
|
+ };
|
|
|
+ },
|
|
|
+ _scanLineWidth() {
|
|
|
+ const s = this.scaleFactor;
|
|
|
+ return this.compactScanLine ? 1 : Math.max(2, Math.round(5 * s));
|
|
|
},
|
|
|
startAutoScan() {
|
|
|
this.stopAutoScan();
|
|
|
@@ -145,7 +211,12 @@ export default {
|
|
|
this._scanListener = (nowSec) => {
|
|
|
const realMax = this.getMaxTime();
|
|
|
this.internalTime = nowSec % realMax;
|
|
|
- if (this.$_chart) this.updateScanLine();
|
|
|
+ if (this.$_chart) {
|
|
|
+ // clipToActive 模式:activeTime 变了→ effectivePhaseData 跟着变 → 必须全量重绘
|
|
|
+ // 普通模式:只动扫描线即可
|
|
|
+ if (this.clipToActive) this.updateChart();
|
|
|
+ else this.updateScanLine();
|
|
|
+ }
|
|
|
this.$emit('scan-tick', this.internalTime);
|
|
|
};
|
|
|
joinGlobalTimer(this._scanListener);
|
|
|
@@ -168,7 +239,6 @@ export default {
|
|
|
updateScanLine() {
|
|
|
if (!this.$_chart) return;
|
|
|
this.updateScale();
|
|
|
- const s = this.scaleFactor;
|
|
|
const realMaxTime = this.getMaxTime();
|
|
|
this.$_chart.setOption({
|
|
|
series: [{
|
|
|
@@ -176,16 +246,8 @@ export default {
|
|
|
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 },
|
|
|
+ label: this._scanLabel(realMaxTime),
|
|
|
+ lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: this._scanLineWidth(), z: 100 },
|
|
|
data: [{ xAxis: this.activeTime }]
|
|
|
}
|
|
|
}]
|
|
|
@@ -202,35 +264,58 @@ export default {
|
|
|
const yAxisData = isTwoRows ? ['Track 0', 'Track 1'] : ['Track 0'];
|
|
|
const realMaxTime = this.getMaxTime();
|
|
|
|
|
|
+ // 阶段刻度区动态高度:根据预估字号紧贴下沉,避免固定 18*s 在 32 阶段时浪费空间
|
|
|
+ // gap = 刻度短线底端 与 色块顶端 的视觉间距
|
|
|
+ let axisTop = 0;
|
|
|
+ const TICK_GAP = 3;
|
|
|
+ if (this.showAxis) {
|
|
|
+ const greenCount = (this.phaseData || []).filter(p => p[0] === 0 && p[5] === 'green').length || 1;
|
|
|
+ const byCount = Math.max(10, 14 - Math.max(0, greenCount - 4) * 0.25);
|
|
|
+ const fs = Math.max(10, Math.min(Math.round(14 * s), Math.floor(byCount)));
|
|
|
+ const tickHalf = Math.max(3, Math.round(fs * 0.4));
|
|
|
+ axisTop = Math.ceil(Math.max(2 * tickHalf + TICK_GAP, tickHalf + TICK_GAP + fs / 2) + 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ // grid.top/bottom 留白策略:
|
|
|
+ // - 紧凑模式(compactScanLine):按 badge 自适应取大于 axisTop 的最小值,bottom 也压缩到 4*s
|
|
|
+ // - 普通模式(默认 4 阶段):showScanLineLabel 或 showAxis 都给原始 35*s/10*s 留白,确保大 badge 能完整渲染
|
|
|
+ let topReserve;
|
|
|
+ let bottomReserve;
|
|
|
+ if (this.showScanLineLabel) {
|
|
|
+ if (this.compactScanLine) {
|
|
|
+ const badgeScale = Math.min(s, this.vScaleFactor);
|
|
|
+ // badge 总高 ≈ fontSize + 上下 padding*2 ≈ 10*scale + 2 (≥ 10px);再 +2px 透气
|
|
|
+ topReserve = Math.max(axisTop, Math.max(10, Math.round(12 * badgeScale) + 2));
|
|
|
+ bottomReserve = Math.round(4 * s);
|
|
|
+ } else {
|
|
|
+ topReserve = Math.round(35 * s);
|
|
|
+ bottomReserve = Math.round(10 * s);
|
|
|
+ }
|
|
|
+ } else if (this.showAxis) {
|
|
|
+ topReserve = axisTop;
|
|
|
+ bottomReserve = Math.round(4 * s);
|
|
|
+ } else {
|
|
|
+ topReserve = 0;
|
|
|
+ bottomReserve = 0;
|
|
|
+ }
|
|
|
+
|
|
|
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
|
|
|
- },
|
|
|
+ grid: { left: 0, right: 0, top: topReserve, bottom: bottomReserve, 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,
|
|
|
+ data: this.effectivePhaseData,
|
|
|
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 } ]
|
|
|
+ label: this._scanLabel(realMaxTime),
|
|
|
+ lineStyle: { color: COLORS.MARK_BLUE, type: 'solid', width: this._scanLineWidth(), z: 100 },
|
|
|
+ data: [{ xAxis: this.activeTime }]
|
|
|
}
|
|
|
}]
|
|
|
};
|
|
|
@@ -266,25 +351,36 @@ export default {
|
|
|
|
|
|
// 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.push(realMaxTime);
|
|
|
stagePoints = Array.from(new Set(stagePoints)).sort((a, b) => a - b);
|
|
|
|
|
|
+ // 自适应字号:受阶段数 + 每段像素宽度双重约束,但下限保持可读性 (≥10px)
|
|
|
+ const stageCount = Math.max(1, stagePoints.length - 1);
|
|
|
+ const cellW = params.coordSys.width / stageCount;
|
|
|
+ const byCount = Math.max(10, 14 - Math.max(0, stageCount - 4) * 0.25);
|
|
|
+ const byWidth = Math.max(10, cellW * 0.36);
|
|
|
+ const stageFontSize = Math.max(10, Math.min(Math.round(14 * s), Math.floor(Math.min(byCount, byWidth))));
|
|
|
+ const tickHalf = Math.max(3, Math.round(stageFontSize * 0.4));
|
|
|
+ const textHalf = Math.max(7, Math.round(stageFontSize * 1.0));
|
|
|
+ // axisBaseY:tick 底端 与 色块顶边 留 3px 间隙
|
|
|
+ // 与 getChartOption 里 axisTop 计算的 TICK_GAP 严格对齐,避免刻度/文字越界
|
|
|
+ const TICK_GAP = 3;
|
|
|
+ const axisBaseY = params.coordSys.y - tickHalf - TICK_GAP;
|
|
|
+
|
|
|
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)) } });
|
|
|
+ children.push({ type: 'line', shape: { x1: x, y1: axisBaseY - tickHalf, x2: x, y2: axisBaseY + tickHalf }, style: { stroke: COLORS.AXIS_LINE, lineWidth: 1 } });
|
|
|
});
|
|
|
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' } });
|
|
|
+ children.push({ type: 'line', shape: { x1: startX, y1: axisBaseY, x2: midX - textHalf, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: 1 } });
|
|
|
+ children.push({ type: 'line', shape: { x1: midX + textHalf, y1: axisBaseY, x2: endX, y2: axisBaseY }, style: { stroke: COLORS.AXIS_LINE, lineWidth: 1 } });
|
|
|
+ children.push({ type: 'text', style: { text: `S${i + 1}`, x: midX, y: axisBaseY, fill: COLORS.TEXT_LIGHT, fontSize: stageFontSize, align: 'center', verticalAlign: 'middle', fontWeight: 'bold' } });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -432,7 +528,7 @@ export default {
|
|
|
// 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)) } });
|
|
|
+ children.push({ type: 'line', shape: { x1: start[0], y1: dividerY, x2: end[0], y2: dividerY }, style: { stroke: COLORS.DIVIDER_LINE, lineWidth: 1 } });
|
|
|
}
|
|
|
|
|
|
return { type: 'group', children: children };
|