|
|
@@ -2,6 +2,10 @@
|
|
|
<div class="map-wrapper" ref="wrapper">
|
|
|
<div class="konva-container" ref="konvaContainer"></div>
|
|
|
|
|
|
+ <div v-show="toggleVisible" class="display-mode-toggle" :style="toggleStyle">
|
|
|
+ <SegmentedRadio v-model="displayMode" :options="displayModeOptions" size="auto" />
|
|
|
+ </div>
|
|
|
+
|
|
|
<div class="corner-videos-overlay" v-if="hasAnyVideo" :style="{ width: stageWidth + 'px', height: stageHeight + 'px' }">
|
|
|
|
|
|
<div v-if="videoUrls.nw" class="video-corner top-left">
|
|
|
@@ -55,6 +59,16 @@
|
|
|
<script>
|
|
|
import Konva from 'konva';
|
|
|
import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
|
|
|
+import SegmentedRadio from '@/components/ui/SegmentedRadio.vue';
|
|
|
+
|
|
|
+// 检测器锚点 = 原摄像机所在的 world 坐标(armNode 局部 (-80,-190) 经 arm 旋转后)。
|
|
|
+// 图标位于锚点上方,下方一行 [流量/占有率(右对齐) ①]。
|
|
|
+const DETECTOR_POSITIONS = {
|
|
|
+ N: { x: 370, y: 100 },
|
|
|
+ E: { x: 800, y: 370 },
|
|
|
+ S: { x: 530, y: 800 },
|
|
|
+ W: { x: 100, y: 530 },
|
|
|
+};
|
|
|
|
|
|
// 方向箭头 SVG 路径映射
|
|
|
// 所有方向臂共用同一套图标(以N方向/向下驶入为基准),由 createRoadArm 的 rotation 自动旋转
|
|
|
@@ -109,7 +123,12 @@ function loadSvgImage(svgUrl, fillColor) {
|
|
|
export default {
|
|
|
name: 'IntersectionMapVideos',
|
|
|
components: {
|
|
|
- XgVideoPlayer
|
|
|
+ XgVideoPlayer,
|
|
|
+ SegmentedRadio,
|
|
|
+ },
|
|
|
+ inject: {
|
|
|
+ // 用 default 兜底:IntersectionMapVideos 也可能被独立测试或其它非 DashboardLayout 父级使用
|
|
|
+ dialogManager: { default: null },
|
|
|
},
|
|
|
props: {
|
|
|
// 1. 路口数字孪生数据
|
|
|
@@ -147,9 +166,28 @@ export default {
|
|
|
stageWidth: 900, // 当前画布缩放后的真实宽度
|
|
|
stageHeight: 900, // 当前画布缩放后的真实高度
|
|
|
activeVideos: { nw: false, ne: false, sw: false, se: false },
|
|
|
+ // 视频/检测器 切换
|
|
|
+ displayMode: 'video',
|
|
|
+ displayModeOptions: [
|
|
|
+ { label: '视频', value: 'video' },
|
|
|
+ { label: '检测器', value: 'detector' },
|
|
|
+ ],
|
|
|
+ toggleVisible: true, // wrapper < 240px 时彻底隐藏(窗口太小放不下)
|
|
|
+ toggleScale: 1, // 自适应缩放:wrapper 宽度低于全尺寸阈值时按比例缩小
|
|
|
+ // 检测器节点(直接挂在 layer 上,不进 armNode;位置见 DETECTOR_POSITIONS)
|
|
|
+ // 注意:detectorBase / detectorTimer 必须不带 `_` 前缀,否则 Vue 2 不会代理到 this(导致 undefined 错误)
|
|
|
+ detectorNodes: { N: null, E: null, S: null, W: null },
|
|
|
+ detectorBase: { N: null, E: null, S: null, W: null },
|
|
|
+ detectorTimer: null,
|
|
|
};
|
|
|
},
|
|
|
computed: {
|
|
|
+ toggleStyle() {
|
|
|
+ return {
|
|
|
+ transform: `scale(${this.toggleScale})`,
|
|
|
+ transformOrigin: 'top right',
|
|
|
+ };
|
|
|
+ },
|
|
|
// 判断是否传入了至少一个视频,如果没有,直接不渲染遮罩层提升性能
|
|
|
hasAnyVideo() {
|
|
|
if (!this.videoUrls) return false;
|
|
|
@@ -168,6 +206,8 @@ export default {
|
|
|
this.initResizeObserver();
|
|
|
},
|
|
|
beforeDestroy() {
|
|
|
+ this.stopDetectorFluctuation();
|
|
|
+ this.closeDetectorDialog();
|
|
|
if (this.resizeObserver) this.resizeObserver.disconnect();
|
|
|
if (this.stage) this.stage.destroy();
|
|
|
},
|
|
|
@@ -181,7 +221,10 @@ export default {
|
|
|
this.updateDynamicSignals();
|
|
|
},
|
|
|
deep: true
|
|
|
- }
|
|
|
+ },
|
|
|
+ displayMode() {
|
|
|
+ this.applyDisplayMode();
|
|
|
+ },
|
|
|
},
|
|
|
methods: {
|
|
|
openVideo(corner) {
|
|
|
@@ -246,6 +289,14 @@ export default {
|
|
|
this.stage.width(this.stageWidth);
|
|
|
this.stage.height(this.stageHeight);
|
|
|
this.stage.scale({ x: scale, y: scale });
|
|
|
+
|
|
|
+ // 自适应:wrapper >= 600 时全尺寸;240~600 之间线性缩放(最小 0.55);< 240 直接隐藏
|
|
|
+ if (containerWidth < 240) {
|
|
|
+ this.toggleVisible = false;
|
|
|
+ } else {
|
|
|
+ this.toggleVisible = true;
|
|
|
+ this.toggleScale = Math.min(1, Math.max(0.55, containerWidth / 600));
|
|
|
+ }
|
|
|
},
|
|
|
|
|
|
createRoadArm(x, y, rotation) {
|
|
|
@@ -325,6 +376,162 @@ export default {
|
|
|
return group;
|
|
|
},
|
|
|
|
|
|
+ /**
|
|
|
+ * 检测器条目布局(N 数据行在图标上方,其他方向在下方;编号在左、文字两行在右):
|
|
|
+ * N 方向: 其他方向:
|
|
|
+ * ① 流量:1230 [icon]
|
|
|
+ * 占有率:50% ① 流量:1230
|
|
|
+ * [icon] 占有率:50%
|
|
|
+ */
|
|
|
+ createDetectorIcon(num, dir, x, y) {
|
|
|
+ const group = new Konva.Group({ x, y });
|
|
|
+ const stroke = '#7fb6ff';
|
|
|
+ const fill = '#3a7fd1';
|
|
|
+
|
|
|
+ // 1) 顶部图标:沿用原 球机 造型(圆角矩形 + 内嵌镜头圆 + 底部支架横杆+竖杆)
|
|
|
+ const iconG = new Konva.Group({ x: 0, y: 2 });
|
|
|
+ iconG.add(new Konva.Rect({
|
|
|
+ x: -18, y: -32, width: 36, height: 32,
|
|
|
+ stroke, strokeWidth: 2.5, cornerRadius: 8,
|
|
|
+ fill: 'rgba(58,127,209,0.2)',
|
|
|
+ }));
|
|
|
+ iconG.add(new Konva.Circle({ x: 0, y: -16, radius: 7, stroke, strokeWidth: 2.5 }));
|
|
|
+ iconG.add(new Konva.Line({ points: [-13, 0, 13, 0], stroke, strokeWidth: 2.5, lineCap: 'round' }));
|
|
|
+ iconG.add(new Konva.Line({ points: [0, 0, 0, 8], stroke, strokeWidth: 2.5 }));
|
|
|
+ group.add(iconG);
|
|
|
+
|
|
|
+ // 2) 数据行 Y 偏移:N 在图标上方,其他在下方
|
|
|
+ const dataY = dir === 'N' ? -62 : 42;
|
|
|
+
|
|
|
+ // 3) 编号圆圈(左侧)
|
|
|
+ const badgeCx = -55;
|
|
|
+ group.add(new Konva.Circle({ x: badgeCx, y: dataY, radius: 13, fill, stroke, strokeWidth: 1.5 }));
|
|
|
+ group.add(new Konva.Text({
|
|
|
+ x: badgeCx - 13, y: dataY - 9, width: 26, align: 'center',
|
|
|
+ text: String(num), fontSize: 16, fontStyle: 'bold', fill: '#fff',
|
|
|
+ }));
|
|
|
+
|
|
|
+ // 4) 流量 / 占有率两行(编号右侧 6px 间隔)
|
|
|
+ const textX = badgeCx + 13 + 6;
|
|
|
+ const flowText = new Konva.Text({
|
|
|
+ x: textX, y: dataY - 18, text: '', fontSize: 18, fontStyle: 'bold', fill: '#fff', fontFamily: 'monospace',
|
|
|
+ });
|
|
|
+ const occText = new Konva.Text({
|
|
|
+ x: textX, y: dataY + 2, text: '', fontSize: 18, fontStyle: 'bold', fill: '#fff', fontFamily: 'monospace',
|
|
|
+ });
|
|
|
+ group.add(flowText, occText);
|
|
|
+ group.flowText = flowText;
|
|
|
+ group.occText = occText;
|
|
|
+ return group;
|
|
|
+ },
|
|
|
+
|
|
|
+ /** (重)创建四个方向的检测器节点;如已存在先 destroy */
|
|
|
+ renderDetectors() {
|
|
|
+ const cfg = (this.mapData && this.mapData.armsConfig) || {};
|
|
|
+ ['N', 'E', 'S', 'W'].forEach(dir => {
|
|
|
+ if (this.detectorNodes[dir]) {
|
|
|
+ this.detectorNodes[dir].destroy();
|
|
|
+ this.detectorNodes[dir] = null;
|
|
|
+ }
|
|
|
+ const det = cfg[dir] && cfg[dir].detector;
|
|
|
+ if (!det) return;
|
|
|
+ const pos = DETECTOR_POSITIONS[dir];
|
|
|
+ const node = this.createDetectorIcon(det.index, dir, pos.x, pos.y);
|
|
|
+ node.visible(this.displayMode === 'detector');
|
|
|
+ this.layer.add(node);
|
|
|
+ this.detectorNodes[dir] = node;
|
|
|
+ });
|
|
|
+ this.syncDetectorBase();
|
|
|
+ this.updateDetectorTexts();
|
|
|
+ },
|
|
|
+
|
|
|
+ /** 把 mapData 的检测器初值缓存为波动中心;显示值初始化为基础值 */
|
|
|
+ syncDetectorBase() {
|
|
|
+ const cfg = (this.mapData && this.mapData.armsConfig) || {};
|
|
|
+ ['N', 'E', 'S', 'W'].forEach(dir => {
|
|
|
+ const det = cfg[dir] && cfg[dir].detector;
|
|
|
+ this.detectorBase[dir] = det ? { flow: det.flow, occupancy: det.occupancy } : null;
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ /** 检测器模式:每 5s 让 flow ±15%、occupancy ±5 绝对值围绕基础值随机走步 */
|
|
|
+ fluctuateDetectors() {
|
|
|
+ ['N', 'E', 'S', 'W'].forEach(dir => {
|
|
|
+ const base = this.detectorBase[dir];
|
|
|
+ const node = this.detectorNodes[dir];
|
|
|
+ if (!base || !node) return;
|
|
|
+ const flow = Math.max(0, Math.round(base.flow + (Math.random() - 0.5) * 2 * base.flow * 0.15));
|
|
|
+ const occupancy = Math.max(0, Math.min(100, Math.round(base.occupancy + (Math.random() - 0.5) * 10)));
|
|
|
+ node.flowText.text(`流量:${flow}`);
|
|
|
+ node.occText.text(`占有率:${occupancy}%`);
|
|
|
+ });
|
|
|
+ if (this.layer) this.layer.batchDraw();
|
|
|
+ },
|
|
|
+
|
|
|
+ /** 用基础值刷一次显示文本(进入检测器模式或 mapData 切换时调用) */
|
|
|
+ updateDetectorTexts() {
|
|
|
+ ['N', 'E', 'S', 'W'].forEach(dir => {
|
|
|
+ const base = this.detectorBase[dir];
|
|
|
+ const node = this.detectorNodes[dir];
|
|
|
+ if (!base || !node) return;
|
|
|
+ node.flowText.text(`流量:${base.flow}`);
|
|
|
+ node.occText.text(`占有率:${base.occupancy}%`);
|
|
|
+ });
|
|
|
+ if (this.layer) this.layer.batchDraw();
|
|
|
+ },
|
|
|
+
|
|
|
+ startDetectorFluctuation() {
|
|
|
+ if (this.detectorTimer) return;
|
|
|
+ this.detectorTimer = setInterval(this.fluctuateDetectors, 5000);
|
|
|
+ },
|
|
|
+
|
|
|
+ stopDetectorFluctuation() {
|
|
|
+ if (this.detectorTimer) {
|
|
|
+ clearInterval(this.detectorTimer);
|
|
|
+ this.detectorTimer = null;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /** 切换 video/detector:用 visible() 而不是 destroy 重建,开销最小 */
|
|
|
+ applyDisplayMode() {
|
|
|
+ const showDetector = this.displayMode === 'detector';
|
|
|
+ ['N', 'E', 'S', 'W'].forEach(dir => {
|
|
|
+ const arm = this.armsNodes[dir];
|
|
|
+ if (arm && arm.cameraNode) arm.cameraNode.visible(!showDetector);
|
|
|
+ if (this.detectorNodes[dir]) this.detectorNodes[dir].visible(showDetector);
|
|
|
+ });
|
|
|
+ if (this.layer) this.layer.batchDraw();
|
|
|
+ if (showDetector) {
|
|
|
+ this.startDetectorFluctuation();
|
|
|
+ this.openDetectorDialog();
|
|
|
+ } else {
|
|
|
+ this.stopDetectorFluctuation();
|
|
|
+ this.closeDetectorDialog();
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /** 检测器模式下打开弹窗:DetectorTable 居中展示「检测器运行数据监视」 */
|
|
|
+ openDetectorDialog() {
|
|
|
+ if (!this.dialogManager || typeof this.dialogManager.openDialog !== 'function') return;
|
|
|
+ this.dialogManager.openDialog({
|
|
|
+ id: `detector-monitor-${this._uid}`,
|
|
|
+ title: '检测器运行数据监视',
|
|
|
+ component: 'DetectorTable',
|
|
|
+ width: 620,
|
|
|
+ height: 360,
|
|
|
+ noPadding: false,
|
|
|
+ // 用户手动关闭弹窗时把按钮切回视频模式,保持二者状态同步
|
|
|
+ onClose: () => {
|
|
|
+ if (this.displayMode === 'detector') this.displayMode = 'video';
|
|
|
+ },
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ closeDetectorDialog() {
|
|
|
+ if (!this.dialogManager || typeof this.dialogManager.closeDialog !== 'function') return;
|
|
|
+ this.dialogManager.closeDialog(`detector-monitor-${this._uid}`);
|
|
|
+ },
|
|
|
+
|
|
|
createCameraIcon(type, x, y) {
|
|
|
const group = new Konva.Group({ x, y });
|
|
|
if (type === 1) {
|
|
|
@@ -368,6 +575,8 @@ export default {
|
|
|
}
|
|
|
});
|
|
|
});
|
|
|
+ this.renderDetectors();
|
|
|
+ this.applyDisplayMode();
|
|
|
this.layer.draw();
|
|
|
},
|
|
|
|
|
|
@@ -456,13 +665,28 @@ export default {
|
|
|
}
|
|
|
|
|
|
.konva-container {
|
|
|
- position: absolute;
|
|
|
+ position: absolute;
|
|
|
top: 50%;
|
|
|
left: 50%;
|
|
|
transform: translate(-50%, -50%);
|
|
|
z-index: 1; /* 图层垫底 */
|
|
|
}
|
|
|
|
|
|
+.display-mode-toggle {
|
|
|
+ position: absolute;
|
|
|
+ top: 8px;
|
|
|
+ right: 8px;
|
|
|
+ z-index: 50; /* 高于 konva-container(z=1),避免被画布盖住 */
|
|
|
+ width: 120px;
|
|
|
+ font-size: 12px;
|
|
|
+ background: rgba(5, 22, 45, 0.92); /* 不透明深底,多窗口下按钮压住画布也能看清 */
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+.display-mode-toggle ::v-deep .radio-item {
|
|
|
+ padding: 2px 6px;
|
|
|
+ line-height: 1.3;
|
|
|
+}
|
|
|
+
|
|
|
/* ================= 视频遮罩与挂件 ================= */
|
|
|
.corner-videos-overlay {
|
|
|
position: absolute;
|