| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579 |
- <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">
- <template v-if="activeVideos.nw">
- <XgVideoPlayer :src="videoUrls.nw" :autoplay="true" />
- <div class="close-btn" title="关闭视频" @click="closeVideo('nw')">✕</div>
- </template>
- <div v-else class="empty-state" @click="openVideo('nw')" title="点击关联视频">
- <span class="empty-tag">关联视频</span>
- <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
- </div>
- </div>
- <div v-if="videoUrls.ne" class="video-corner top-right">
- <template v-if="activeVideos.ne">
- <XgVideoPlayer :src="videoUrls.ne" :autoplay="true" />
- <div class="close-btn" title="关闭视频" @click="closeVideo('ne')">✕</div>
- </template>
- <div v-else class="empty-state" @click="openVideo('ne')" title="点击关联视频">
- <span class="empty-tag">关联视频</span>
- <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
- </div>
- </div>
- <div v-if="videoUrls.sw" class="video-corner bottom-left">
- <template v-if="activeVideos.sw">
- <XgVideoPlayer :src="videoUrls.sw" :autoplay="true" />
- <div class="close-btn" title="关闭视频" @click="closeVideo('sw')">✕</div>
- </template>
- <div v-else class="empty-state" @click="openVideo('sw')" title="点击关联视频">
- <span class="empty-tag">关联视频</span>
- <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
- </div>
- </div>
- <div v-if="videoUrls.se" class="video-corner bottom-right">
- <template v-if="activeVideos.se">
- <XgVideoPlayer :src="videoUrls.se" :autoplay="true" />
- <div class="close-btn" title="关闭视频" @click="closeVideo('se')">✕</div>
- </template>
- <div v-else class="empty-state" @click="openVideo('se')" title="点击关联视频">
- <span class="empty-tag">关联视频</span>
- <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
- </div>
- </div>
- </div>
- </div>
- </template>
- <script>
- import Konva from 'konva';
- import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
- // 方向箭头 SVG 路径映射
- // 所有方向臂共用同一套图标(以N方向/向下驶入为基准),由 createRoadArm 的 rotation 自动旋转
- // R(右转)用左转图标水平翻转
- const arrowSvgMap = {
- S: require('@/assets/images/svg/icon_straight_down.svg'),
- L: require('@/assets/images/svg/icon_turn_down_left.svg'),
- U: require('@/assets/images/svg/icon_turn_down_left_uturn.svg'),
- R: require('@/assets/images/svg/icon_turn_down_left.svg'), // 右转用左转图标,渲染时水平翻转
- };
- // SVG 原始文本映射(内联,避免 webpack loader 问题)
- const svgRawCache = {};
- // 首次加载时通过 Image → Canvas 获取不到 SVG 内容,所以直接用内联方式
- // 从 require 得到的 URL(可能是 base64 或路径)加载图片
- const imgCache = {};
- function loadSvgImage(svgUrl, fillColor) {
- const cacheKey = svgUrl + '|' + fillColor;
- if (imgCache[cacheKey]) return Promise.resolve(imgCache[cacheKey]);
- // 如果是 base64 data URL,解码后替换颜色
- if (svgUrl.startsWith('data:image/svg+xml;base64,')) {
- const base64 = svgUrl.split(',')[1];
- let svgText = atob(base64);
- svgText = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
- const encoded = btoa(svgText);
- const dataUrl = 'data:image/svg+xml;base64,' + encoded;
- return new Promise(resolve => {
- const img = new Image();
- img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
- img.src = dataUrl;
- });
- }
- // 普通 URL,fetch 后替换颜色
- return fetch(svgUrl)
- .then(r => r.text())
- .then(svgText => {
- const colored = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
- const blob = new Blob([colored], { type: 'image/svg+xml' });
- const url = URL.createObjectURL(blob);
- return new Promise(resolve => {
- const img = new Image();
- img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
- img.src = url;
- });
- });
- }
- export default {
- name: 'IntersectionMapVideos',
- components: {
- XgVideoPlayer
- },
- 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, // 当前画布缩放后的真实高度
- activeVideos: { nw: false, ne: false, sw: false, se: false },
- };
- },
- computed: {
- // 判断是否传入了至少一个视频,如果没有,直接不渲染遮罩层提升性能
- hasAnyVideo() {
- if (!this.videoUrls) return false;
- return ['nw', 'ne', 'sw', 'se'].some(corner => {
- const v = this.videoUrls[corner];
- return v && (typeof v === 'string' ? v : v.url);
- });
- }
- },
- 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: {
- openVideo(corner) {
- this.$set(this.activeVideos, corner, true);
- },
- closeVideo(corner) {
- this.$set(this.activeVideos, corner, false);
- },
- // ================= 以下为原有的 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 maxH = 40; // 最大高度
- const group = new Konva.Group({ x, y });
- if (type === 'R') group.scaleX(-1);
- group._arrowMeta = { type };
- const svgUrl = arrowSvgMap[type];
- if (svgUrl) {
- loadSvgImage(svgUrl, color).then(imgObj => {
- // 按原始比例等比缩放,高度不超过 maxH
- const natW = imgObj.naturalWidth || imgObj.width;
- const natH = imgObj.naturalHeight || imgObj.height;
- const scale = Math.min(maxH / natH, 1);
- const w = Math.round(natW * scale);
- const h = Math.round(natH * scale);
- const konvaImg = new Konva.Image({
- image: imgObj,
- x: -w / 2,
- y: -h - 5,
- width: w,
- height: h,
- name: 'arrowImg'
- });
- group.add(konvaImg);
- if (this.layer) this.layer.draw();
- });
- }
- 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 config = this.mapData.armsConfig || {};
- 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 nsActiveTypes = signals.ns.activeArrowTypes || [];
- const ewActiveTypes = signals.ew.activeArrowTypes || [];
- const dyeArm = (dir, armNode, pedColor, vehicleColor, activeTypes) => {
- // 灯带颜色(人行道信号)
- armNode.lightGroup.getChildren().forEach(r => r.fill(pedColor));
- // 箭头按 lane type 用不同颜色的 SVG 替换
- const lanes = (config[dir] && config[dir].lanes) || [];
- Object.keys(armNode.arrowNodes).forEach(index => {
- const arr = armNode.arrowNodes[index];
- if (!arr) return;
- const laneType = lanes[index];
- const isActive = activeTypes.length > 0 && activeTypes.includes(laneType);
- const targetColor = isActive ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
- const meta = arr._arrowMeta;
- if (!meta) return;
- const svgUrl = arrowSvgMap[meta.type];
- if (!svgUrl) return;
- loadSvgImage(svgUrl, targetColor).then(imgObj => {
- const existing = arr.findOne('.arrowImg');
- if (existing) {
- existing.image(imgObj);
- } else {
- const maxH = 40;
- const natW = imgObj.naturalWidth || imgObj.width;
- const natH = imgObj.naturalHeight || imgObj.height;
- const scale = Math.min(maxH / natH, 1);
- const w = Math.round(natW * scale);
- const h = Math.round(natH * scale);
- arr.add(new Konva.Image({
- image: imgObj,
- x: -w / 2,
- y: -h - 5,
- width: w,
- height: h,
- name: 'arrowImg'
- }));
- }
- if (this.layer) this.layer.draw();
- });
- });
- };
- // 灯带代表人行道:P1/P3绿灯期间正常(车绿人红、车红人绿),其余时段人行道全红
- const pedAllRed = signals.pedAllRed || false;
- const nsPedColor = pedAllRed ? this.C.SIGNAL_RED : (signals.ns.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN);
- const ewPedColor = pedAllRed ? this.C.SIGNAL_RED : (signals.ew.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN);
- dyeArm('N', this.armsNodes.N, nsPedColor, nsColor, nsActiveTypes);
- dyeArm('S', this.armsNodes.S, nsPedColor, nsColor, nsActiveTypes);
- dyeArm('E', this.armsNodes.E, ewPedColor, ewColor, ewActiveTypes);
- dyeArm('W', this.armsNodes.W, ewPedColor, ewColor, ewActiveTypes);
- 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;
- border-radius: 2px;
- overflow: hidden;
- }
- .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;
- border-radius: 2px;
- }
- /* 四角贴死四个角 */
- .top-left { top: -1px; left: -1px; }
- .top-right { top: -1px; right: -1px; }
- .bottom-left { bottom: -1px; left: -1px; }
- .bottom-right { bottom: -1px; right: -1px; }
- /* ================= 关联视频空状态 & 关闭按钮 ================= */
- .close-btn {
- position: absolute;
- top: 4%;
- right: 4%;
- background: rgba(0, 0, 0, 0.6);
- color: #fff;
- width: 8%;
- height: 8%;
- min-width: 14px;
- min-height: 14px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- transition: background 0.3s;
- z-index: 10;
- font-size: clamp(8px, 4%, 14px);
- }
- .close-btn:hover { background: rgba(0, 0, 0, 1); }
- .empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- height: 100%;
- cursor: pointer;
- transition: all 0.3s ease;
- background: #112445;
- border-radius: 2px;
- overflow: hidden;
- }
- .empty-state:hover {
- background: rgba(68, 138, 255, 0.1);
- }
- .empty-state:hover .empty-tag {
- background: rgba(68, 138, 255, 1);
- box-shadow: 0 0 10px rgba(68, 138, 255, 0.5);
- }
- .empty-state:hover .camera-image {
- transform: scale(1.05);
- transition: transform 0.3s ease;
- }
- .empty-tag {
- background: rgba(68, 138, 255, 0.8);
- color: #fff;
- padding: 2% 6%;
- border-radius: 4px;
- font-size: clamp(8px, 5%, 14px);
- margin-bottom: 6%;
- letter-spacing: 1px;
- transition: all 0.3s ease;
- white-space: nowrap;
- }
- .camera-image {
- width: 30%;
- max-width: 50px;
- height: auto;
- object-fit: contain;
- opacity: 0.8;
- }
- /* xgplayer 填满角落容器 */
- .video-corner .xg-video-player {
- width: 100%;
- height: 100%;
- }
- .video-corner >>> .xgplayer {
- width: 100% !important;
- height: 100% !important;
- }
- .video-corner >>> .xgplayer video {
- object-fit: cover;
- }
- </style>
|