|
|
@@ -0,0 +1,352 @@
|
|
|
+<template>
|
|
|
+ <div class="map-wrapper" ref="wrapper">
|
|
|
+ <div class="konva-container" ref="konvaContainer"></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">
|
|
|
+ <video :src="videoUrls.nw" autoplay loop muted class="corner-video"></video>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="videoUrls.ne" class="video-corner top-right">
|
|
|
+ <video :src="videoUrls.ne" autoplay loop muted class="corner-video"></video>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="videoUrls.sw" class="video-corner bottom-left">
|
|
|
+ <video :src="videoUrls.sw" autoplay loop muted class="corner-video"></video>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="videoUrls.se" class="video-corner bottom-right">
|
|
|
+ <video :src="videoUrls.se" autoplay loop muted class="corner-video"></video>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import Konva from 'konva';
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'IntersectionMapVideos',
|
|
|
+ props: {
|
|
|
+ // 1. 路口数字孪生数据
|
|
|
+ mapData: {
|
|
|
+ type: Object,
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+ // 2. 【新增】:四路视频地址配置
|
|
|
+ videoUrls: {
|
|
|
+ type: Object,
|
|
|
+ default: () => ({
|
|
|
+ nw: '', ne: '', sw: '', se: ''
|
|
|
+ })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ stage: null,
|
|
|
+ layer: null,
|
|
|
+ armsNodes: {},
|
|
|
+ panelNodes: {},
|
|
|
+ resizeObserver: null,
|
|
|
+ C: {
|
|
|
+ BG: '#212842', ROAD: '#3d3938', YELLOW: '#D9A73D', WHITE: '#E0E0E0',
|
|
|
+ SIGNAL_RED: '#FF5252', SIGNAL_GREEN: '#8DF582',
|
|
|
+ PANEL_BG: 'rgba(30, 30, 40, 0.85)', BLUE: '#448AFF'
|
|
|
+ },
|
|
|
+ sizeConfig: {
|
|
|
+ stageSize: 900,
|
|
|
+ laneWidth: 40,
|
|
|
+ halfRoad: 160,
|
|
|
+ roadWidth: 320,
|
|
|
+ armLength: 350
|
|
|
+ },
|
|
|
+ stageWidth: 900, // 当前画布缩放后的真实宽度
|
|
|
+ stageHeight: 900, // 当前画布缩放后的真实高度
|
|
|
+ };
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ // 判断是否传入了至少一个视频,如果没有,直接不渲染遮罩层提升性能
|
|
|
+ hasAnyVideo() {
|
|
|
+ return this.videoUrls && (this.videoUrls.nw || this.videoUrls.ne || this.videoUrls.sw || this.videoUrls.se);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ this.initKonvaStage();
|
|
|
+ if (this.mapData && Object.keys(this.mapData).length > 0) {
|
|
|
+ this.renderStaticConfig();
|
|
|
+ this.updateDynamicSignals();
|
|
|
+ }
|
|
|
+ this.initResizeObserver();
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ if (this.resizeObserver) this.resizeObserver.disconnect();
|
|
|
+ if (this.stage) this.stage.destroy();
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ mapData: {
|
|
|
+ handler(newData, oldData) {
|
|
|
+ if (!newData) return;
|
|
|
+ if (!oldData || JSON.stringify(newData.armsConfig) !== JSON.stringify(oldData.armsConfig)) {
|
|
|
+ this.renderStaticConfig();
|
|
|
+ }
|
|
|
+ this.updateDynamicSignals();
|
|
|
+ },
|
|
|
+ deep: true
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ // ================= 以下为原有的 Konva 绘制逻辑,完全保持不变 =================
|
|
|
+ initKonvaStage() {
|
|
|
+ const { stageSize, halfRoad, roadWidth } = this.sizeConfig;
|
|
|
+ const center = stageSize / 2;
|
|
|
+
|
|
|
+ this.stage = new Konva.Stage({
|
|
|
+ container: this.$refs.konvaContainer,
|
|
|
+ width: stageSize,
|
|
|
+ height: stageSize
|
|
|
+ });
|
|
|
+ this.layer = new Konva.Layer();
|
|
|
+ this.stage.add(this.layer);
|
|
|
+
|
|
|
+ this.layer.add(new Konva.Rect({ width: stageSize, height: stageSize, fill: this.C.BG }));
|
|
|
+ this.layer.add(new Konva.Rect({ x: center - halfRoad, y: center - halfRoad, width: roadWidth, height: roadWidth, fill: this.C.ROAD }));
|
|
|
+
|
|
|
+ this.armsNodes = {
|
|
|
+ N: this.createRoadArm(center, center - halfRoad, 0),
|
|
|
+ E: this.createRoadArm(center + halfRoad, center, 90),
|
|
|
+ S: this.createRoadArm(center, center + halfRoad, 180),
|
|
|
+ W: this.createRoadArm(center - halfRoad, center, 270)
|
|
|
+ };
|
|
|
+ Object.values(this.armsNodes).forEach(arm => this.layer.add(arm));
|
|
|
+
|
|
|
+ this.createCenterPanel(center);
|
|
|
+ this.layer.draw();
|
|
|
+ },
|
|
|
+
|
|
|
+ initResizeObserver() {
|
|
|
+ this.resizeObserver = new ResizeObserver(() => {
|
|
|
+ window.requestAnimationFrame(() => {
|
|
|
+ this.handleResize();
|
|
|
+ });
|
|
|
+ });
|
|
|
+ if (this.$refs.wrapper) {
|
|
|
+ this.resizeObserver.observe(this.$refs.wrapper);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ handleResize() {
|
|
|
+ if (!this.stage || !this.$refs.wrapper) return;
|
|
|
+ const containerWidth = this.$refs.wrapper.clientWidth;
|
|
|
+ const containerHeight = this.$refs.wrapper.clientHeight;
|
|
|
+ if (containerWidth === 0 || containerHeight === 0) return;
|
|
|
+
|
|
|
+ const scaleX = containerWidth / this.sizeConfig.stageSize;
|
|
|
+ const scaleY = containerHeight / this.sizeConfig.stageSize;
|
|
|
+ const scale = Math.min(scaleX, scaleY);
|
|
|
+
|
|
|
+ // 【核心修改】:记录缩放后的实际物理尺寸,供视频遮罩层使用
|
|
|
+ this.stageWidth = this.sizeConfig.stageSize * scale;
|
|
|
+ this.stageHeight = this.sizeConfig.stageSize * scale;
|
|
|
+
|
|
|
+ this.stage.width(this.stageWidth);
|
|
|
+ this.stage.height(this.stageHeight);
|
|
|
+ this.stage.scale({ x: scale, y: scale });
|
|
|
+ },
|
|
|
+
|
|
|
+ createRoadArm(x, y, rotation) {
|
|
|
+ const { halfRoad, roadWidth, laneWidth } = this.sizeConfig;
|
|
|
+ const group = new Konva.Group({ x, y, rotation });
|
|
|
+
|
|
|
+ group.add(new Konva.Rect({ x: -halfRoad, y: -350, width: roadWidth, height: 350, fill: this.C.ROAD }));
|
|
|
+ group.add(new Konva.Line({ points: [0, -350, 0, -35], stroke: this.C.YELLOW, strokeWidth: 3 }));
|
|
|
+ group.add(new Konva.Path({ data: `M -160 -350 L -160 -30 Q -160 0 -180 0`, stroke: this.C.YELLOW, strokeWidth: 3 }));
|
|
|
+ group.add(new Konva.Path({ data: `M 160 -350 L 160 -30 Q 160 0 180 0`, stroke: this.C.YELLOW, strokeWidth: 3 }));
|
|
|
+ group.add(new Konva.Line({ points: [-160, -35, 0, -35], stroke: this.C.WHITE, strokeWidth: 4 }));
|
|
|
+
|
|
|
+ for (let i = 1; i < 4; i++) {
|
|
|
+ let ox = i * laneWidth;
|
|
|
+ group.add(new Konva.Line({ points: [-ox, -35, -ox, -120], stroke: this.C.WHITE, strokeWidth: 2 }));
|
|
|
+ group.add(new Konva.Line({ points: [-ox, -120, -ox, -350], stroke: this.C.WHITE, strokeWidth: 2, dash: [15, 15] }));
|
|
|
+ group.add(new Konva.Line({ points: [ox, -35, ox, -350], stroke: this.C.WHITE, strokeWidth: 2, dash: [15, 15] }));
|
|
|
+ }
|
|
|
+
|
|
|
+ const lightGroup = new Konva.Group();
|
|
|
+ const rectOpts = { y: -16, width: 8, height: 24, cornerRadius: 2, offsetX: 4, offsetY: 12 };
|
|
|
+ for (let lx = -148; lx <= -20; lx += 16) lightGroup.add(new Konva.Rect({ x: lx, ...rectOpts }));
|
|
|
+ for (let rx = 20; rx <= 148; rx += 16) lightGroup.add(new Konva.Rect({ x: rx, ...rectOpts }));
|
|
|
+ group.add(lightGroup);
|
|
|
+ group.lightGroup = lightGroup;
|
|
|
+
|
|
|
+ group.arrowNodes = { 0: null, 1: null, 2: null, 3: null };
|
|
|
+ group.cameraNode = null;
|
|
|
+
|
|
|
+ return group;
|
|
|
+ },
|
|
|
+
|
|
|
+ createCenterPanel(center) {
|
|
|
+ const panelGroup = new Konva.Group({ x: center - 80, y: center - 45 });
|
|
|
+ panelGroup.add(new Konva.Rect({ width: 160, height: 90, fill: this.C.PANEL_BG, cornerRadius: 8 }));
|
|
|
+
|
|
|
+ const labelFont = { fontSize: 18, fontFamily: 'monospace', fontStyle: 'bold', fill: this.C.WHITE };
|
|
|
+ const valueFont = { fontSize: 28, fontFamily: 'monospace', fontStyle: 'bold' };
|
|
|
+
|
|
|
+ this.panelNodes.nsLabel = new Konva.Text({ ...labelFont, x: 15, y: 22, text: '相位-:' });
|
|
|
+ this.panelNodes.nsVal = new Konva.Text({ ...valueFont, x: 90, y: 15, text: '--', fill: this.C.SIGNAL_GREEN });
|
|
|
+
|
|
|
+ this.panelNodes.ewLabel = new Konva.Text({ ...labelFont, x: 15, y: 55, text: '相位-:' });
|
|
|
+ this.panelNodes.ewVal = new Konva.Text({ ...valueFont, x: 90, y: 48, text: '--', fill: this.C.SIGNAL_GREEN });
|
|
|
+
|
|
|
+ panelGroup.add(this.panelNodes.nsLabel, this.panelNodes.nsVal, this.panelNodes.ewLabel, this.panelNodes.ewVal);
|
|
|
+ this.layer.add(panelGroup);
|
|
|
+ },
|
|
|
+
|
|
|
+ createArrowIcon(type, x, y, color = this.C.WHITE) {
|
|
|
+ const group = new Konva.Group({ x, y, scaleX: 0.65, scaleY: 0.65 });
|
|
|
+ group.add(new Konva.Circle({ x: 0, y: -35, radius: 3, fill: color, name: 'colorFill' }));
|
|
|
+ let pathData = '';
|
|
|
+ if (type === 'S') pathData = 'M 0 -35 L 0 0 M -7 -10 L 0 0 L 7 -10';
|
|
|
+ else if (type === 'L') pathData = 'M 0 -35 L 0 -15 Q 0 0 15 0 M 5 -7 L 15 0 L 5 7';
|
|
|
+ else if (type === 'R') pathData = 'M 0 -35 L 0 -15 Q 0 0 -15 0 M -5 -7 L -15 0 L -5 7';
|
|
|
+ else if (type === 'U') pathData = 'M 0 -35 L 0 -15 Q 0 0 14 0 Q 28 0 28 -15 L 28 -25 M 21 -18 L 28 -25 L 35 -18';
|
|
|
+ group.add(new Konva.Path({ data: pathData, stroke: color, strokeWidth: 3, lineCap: 'round', lineJoin: 'round', name: 'colorStroke' }));
|
|
|
+ return group;
|
|
|
+ },
|
|
|
+
|
|
|
+ createCameraIcon(type, x, y) {
|
|
|
+ const group = new Konva.Group({ x, y });
|
|
|
+ if (type === 1) {
|
|
|
+ group.add(new Konva.Line({ points: [-16, -30, 16, -30], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round' }));
|
|
|
+ group.add(new Konva.Line({ points: [0, -30, 0, -10], stroke: this.C.BLUE, strokeWidth: 2.5 }));
|
|
|
+ const body = new Konva.Group({ y: -10, rotation: 15 });
|
|
|
+ body.add(new Konva.Rect({ x: -16, y: -8, width: 32, height: 16, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 2 }));
|
|
|
+ body.add(new Konva.Rect({ x: 16, y: -4, width: 6, height: 8, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 1 }));
|
|
|
+ group.add(body);
|
|
|
+ } else if (type === 2) {
|
|
|
+ group.add(new Konva.Rect({ x: -14, y: -24, width: 28, height: 24, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 6 }));
|
|
|
+ group.add(new Konva.Circle({ x: 0, y: -12, radius: 5, stroke: this.C.BLUE, strokeWidth: 2.5 }));
|
|
|
+ group.add(new Konva.Line({ points: [-10, 0, 10, 0], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round' }));
|
|
|
+ group.add(new Konva.Line({ points: [0, 0, 0, 6], stroke: this.C.BLUE, strokeWidth: 2.5 }));
|
|
|
+ }
|
|
|
+ return group;
|
|
|
+ },
|
|
|
+
|
|
|
+ renderStaticConfig() {
|
|
|
+ const config = this.mapData.armsConfig;
|
|
|
+ if (!config) return;
|
|
|
+
|
|
|
+ Object.keys(config).forEach(dir => {
|
|
|
+ const armData = config[dir];
|
|
|
+ const armNode = this.armsNodes[dir];
|
|
|
+
|
|
|
+ if (armNode.cameraNode) armNode.cameraNode.destroy();
|
|
|
+ if (armData.cameraType > 0) {
|
|
|
+ const cam = this.createCameraIcon(armData.cameraType, -80, -190);
|
|
|
+ armNode.add(cam);
|
|
|
+ armNode.cameraNode = cam;
|
|
|
+ }
|
|
|
+
|
|
|
+ armData.lanes.forEach((type, index) => {
|
|
|
+ if (armNode.arrowNodes[index]) armNode.arrowNodes[index].destroy();
|
|
|
+ if (type) {
|
|
|
+ const lx = -20 - (index * this.sizeConfig.laneWidth);
|
|
|
+ const arrow = this.createArrowIcon(type, lx, -80, this.C.WHITE);
|
|
|
+ armNode.add(arrow);
|
|
|
+ armNode.arrowNodes[index] = arrow;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ this.layer.draw();
|
|
|
+ },
|
|
|
+
|
|
|
+ updateDynamicSignals() {
|
|
|
+ const signals = this.mapData.signals;
|
|
|
+ if (!signals) return;
|
|
|
+
|
|
|
+ const nsColor = signals.ns.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
|
|
|
+ const ewColor = signals.ew.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
|
|
|
+
|
|
|
+ const dyeArm = (armNode, color) => {
|
|
|
+ armNode.lightGroup.getChildren().forEach(r => r.fill(color));
|
|
|
+ Object.values(armNode.arrowNodes).forEach(arr => {
|
|
|
+ if (arr) {
|
|
|
+ arr.findOne('.colorFill').fill(color);
|
|
|
+ arr.findOne('.colorStroke').stroke(color);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ dyeArm(this.armsNodes.N, nsColor);
|
|
|
+ dyeArm(this.armsNodes.S, nsColor);
|
|
|
+ dyeArm(this.armsNodes.E, ewColor);
|
|
|
+ dyeArm(this.armsNodes.W, ewColor);
|
|
|
+
|
|
|
+ this.panelNodes.nsLabel.text(`${signals.ns.phaseName}:`);
|
|
|
+ this.panelNodes.nsVal.text(signals.ns.time.toString().padStart(2, '0')).fill(nsColor);
|
|
|
+
|
|
|
+ this.panelNodes.ewLabel.text(`${signals.ew.phaseName}:`);
|
|
|
+ this.panelNodes.ewVal.text(signals.ew.time.toString().padStart(2, '0')).fill(ewColor);
|
|
|
+
|
|
|
+ this.layer.draw();
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+/* ================= 地图外层 ================= */
|
|
|
+.map-wrapper {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+ background-color: #212842;
|
|
|
+ position: relative; /* 核心:让子元素能在其内部绝对定位 */
|
|
|
+}
|
|
|
+
|
|
|
+.konva-container {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ z-index: 1; /* 图层垫底 */
|
|
|
+}
|
|
|
+
|
|
|
+/* ================= 视频遮罩与挂件 ================= */
|
|
|
+.corner-videos-overlay {
|
|
|
+ position: absolute;
|
|
|
+ /* 【核心修改】:和 Canvas 一样,使用绝对居中对齐 */
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ z-index: 10;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+
|
|
|
+.video-corner {
|
|
|
+ position: absolute;
|
|
|
+ /* 【核心修改】:(900-320)/2 / 900 = 32.222% */
|
|
|
+ width: 32.222%;
|
|
|
+ height: 32.222%;
|
|
|
+ background: #000;
|
|
|
+ pointer-events: auto;
|
|
|
+
|
|
|
+ /* 加上极细的边框,配合内减盒模型,防止尺寸撑大导致错位 */
|
|
|
+ box-sizing: border-box;
|
|
|
+ border: 1px solid rgba(68, 138, 255, 0.4);
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+/* 【核心修改】:去掉所有 margin 和 top/left 间距,严丝合缝贴死四个角 */
|
|
|
+.top-left { top: 0; left: 0; }
|
|
|
+.top-right { top: 0; right: 0; }
|
|
|
+.bottom-left { bottom: 0; left: 0; }
|
|
|
+.bottom-right { bottom: 0; right: 0; }
|
|
|
+
|
|
|
+.corner-video {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+</style>
|