| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783 |
- <template>
- <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>
- </template>
- <script>
- import Konva from 'konva';
- import SegmentedRadio from '@/components/ui/SegmentedRadio.vue';
- import { apiGetDetectorMonitorData } from '@/api';
- // 检测器现挂在 armNode 内部,位置使用 arm-local 坐标(与 createArrowIcon 一致),
- // y=-190 对应原摄像机锚点(路口前方约一倍车道处的虚拟检测门架),由 arm 旋转自动定位到四个方向。
- // 方向箭头 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: {
- SegmentedRadio,
- },
- inject: {
- // 用 default 兜底:IntersectionMapVideos 也可能被独立测试或其它非 DashboardLayout 父级使用
- dialogManager: { default: null },
- },
- props: {
- // 路口数字孪生数据
- mapData: {
- type: Object,
- required: true
- },
- },
- 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, // 当前画布缩放后的真实高度
- // 视频/检测器 切换
- 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',
- };
- },
- },
- mounted() {
- this.initKonvaStage();
- if (this.mapData && Object.keys(this.mapData).length > 0) {
- this.renderStaticConfig();
- this.updateDynamicSignals();
- }
- this.initResizeObserver();
- },
- beforeDestroy() {
- this.stopDetectorPolling();
- this.closeDetectorDialog();
- this.closeAllCameraDialogs();
- 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
- },
- displayMode() {
- this.applyDisplayMode();
- },
- },
- 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 });
- // 自适应: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));
- }
- // multi-view 布局变化(增删面板/expand 切换)会让 .detail-panel-right 重排,
- // 检测器弹窗若已开着,跟随重新定位+尺寸。重入 openDialog 走 existing 分支只更新位置尺寸。
- if (this.displayMode === 'detector') {
- this.openDetectorDialog();
- }
- // 已打开的摄像头视频弹窗按 2×2 网格重排(保留用户的下拉/拖拽不重置)
- this.repositionOpenCameraDialogs();
- },
- 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;
- },
- /** 检测器图标(球机造型)。放在进口道中线、徽章列上方;
- * `armRotation` 用来反向旋转,让所有方向的图标都朝上,与 arm 旋转无关。 */
- createDetectorIcon(armRotation) {
- const stroke = '#7fb6ff';
- const group = new Konva.Group({
- x: -80, y: -240,
- rotation: -armRotation,
- });
- group.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)',
- }));
- group.add(new Konva.Circle({ x: 0, y: -16, radius: 7, stroke, strokeWidth: 2.5 }));
- group.add(new Konva.Line({ points: [-13, 0, 13, 0], stroke, strokeWidth: 2.5, lineCap: 'round' }));
- group.add(new Konva.Line({ points: [0, 0, 0, 8], stroke, strokeWidth: 2.5 }));
- return group;
- },
- /** 检测器编号徽章(小圆 + 数字)。文字反向旋转,保证四个方向都朝上。 */
- createDetectorBadge(lx, ly, num, armRotation) {
- const stroke = '#7fb6ff';
- const fill = '#3a7fd1';
- const group = new Konva.Group();
- group.add(new Konva.Circle({ x: lx, y: ly, radius: 13, fill, stroke, strokeWidth: 1.5 }));
- group.add(new Konva.Text({
- x: lx, y: ly,
- offsetX: 13, offsetY: 9, // 把旋转中心从左上角搬到 (lx, ly)
- width: 26, align: 'center',
- text: String(num), fontSize: 16, fontStyle: 'bold', fill: '#fff',
- rotation: -armRotation,
- }));
- return group;
- },
- /** (重)创建四个方向的检测器组:图标 + 每条车道一个编号徽章。
- * 组挂在 armNode 内,arm 旋转/平移自动定位;图标和数字通过反向旋转保持朝上。 */
- renderDetectors() {
- const cfg = (this.mapData && this.mapData.armsConfig) || {};
- const { laneWidth } = this.sizeConfig;
- const detY = -190; // 与原 cameraNode 锚点一致
- // 全路口连续编号:N→E→S→W 顺时针累加,每条车道一个号;
- // 每个方向内按司机视角"左→右"(arm-local 最外侧 → 最内侧)编号。
- let badgeNum = 1;
- ['N', 'E', 'S', 'W'].forEach(dir => {
- const armNode = this.armsNodes[dir];
- if (!armNode) return;
- // 清理旧检测器组
- if (armNode.detectorGroup) {
- armNode.detectorGroup.destroy();
- armNode.detectorGroup = null;
- }
- this.detectorNodes[dir] = null;
- const armData = cfg[dir];
- if (!armData) return;
- const lanes = armData.lanes || [];
- const armRotation = armNode.rotation() || 0;
- const detGroup = new Konva.Group();
- // 检测器图标(一方向一个,徽章列上方)
- detGroup.add(this.createDetectorIcon(armRotation));
- // 车道徽章
- for (let index = lanes.length - 1; index >= 0; index--) {
- const lx = -20 - index * laneWidth;
- detGroup.add(this.createDetectorBadge(lx, detY, badgeNum++, armRotation));
- }
- detGroup.visible(this.displayMode === 'detector');
- armNode.add(detGroup);
- armNode.detectorGroup = detGroup;
- this.detectorNodes[dir] = detGroup;
- });
- 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;
- });
- },
- /** 用 detectorBase 当前值刷一次显示文本(当前画布版未绘制流量/占有率文字,
- * 保留方法以便未来恢复显示;polling 仍在跑,detectorBase 持续更新供 DetectorTable 等下游消费) */
- updateDetectorTexts() {
- ['N', 'E', 'S', 'W'].forEach(dir => {
- const base = this.detectorBase[dir];
- const node = this.detectorNodes[dir];
- if (!base || !node) return;
- if (node.flowText) node.flowText.text(`流量:${base.flow}`);
- if (node.occText) node.occText.text(`占有率:${base.occupancy}%`);
- });
- if (this.layer) this.layer.batchDraw();
- },
- /** 同源轮询:拉 apiGetDetectorMonitorData,把 armsDetector 写回 detectorBase 并刷新文字。
- * 与 DetectorTable 用同一接口,画布每 5s 跳到与表格同源的当前桶值。 */
- async pollDetectorData() {
- try {
- const id = this.getIntersectionId();
- const res = await apiGetDetectorMonitorData(id);
- const arms = res && res.armsDetector;
- if (!arms) return;
- ['N', 'E', 'S', 'W'].forEach(dir => {
- const d = arms[dir];
- if (d) this.detectorBase[dir] = { flow: d.flow, occupancy: d.occupancy };
- });
- this.updateDetectorTexts();
- } catch (e) {
- console.warn('[IntersectionMapVideos] poll detector failed:', e);
- }
- },
- startDetectorPolling() {
- if (this.detectorTimer) return;
- this.pollDetectorData(); // 进入检测器模式立即拉一次,避免空 5s 等待
- this.detectorTimer = setInterval(this.pollDetectorData, 5000);
- },
- stopDetectorPolling() {
- 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.startDetectorPolling();
- this.openDetectorDialog();
- this.closeAllCameraDialogs(); // 切到检测器:关掉所有摄像头视频弹窗
- } else {
- this.stopDetectorPolling();
- this.closeDetectorDialog(); // 切回视频:关检测器弹窗(视频弹窗按需点开,无需自动开)
- }
- },
- /** 从 mapData 推导出当前路口 id(detectors / cameras 数组里都带 intersectionId) */
- getIntersectionId() {
- const m = this.mapData || {};
- if (Array.isArray(m.detectors) && m.detectors.length) return m.detectors[0].intersectionId || '';
- if (Array.isArray(m.cameras) && m.cameras.length) return m.cameras[0].intersectionId || '';
- return '';
- },
- /** 检测器模式下打开弹窗:默认贴到本路口详情面板右侧(控制方式区域)覆盖显示。
- * 无法定位时回退到居中默认尺寸。SmartDialog 用 1920 设计宽度做缩放,所以这里把
- * 实际像素 rect 换算回设计坐标传过去。 */
- openDetectorDialog() {
- if (!this.dialogManager || typeof this.dialogManager.openDialog !== 'function') return;
- const cfg = {
- id: `detector-monitor-${this._uid}`,
- title: '检测器运行数据监视',
- component: 'DetectorTable',
- noPadding: false,
- // 关闭右下角缩放手柄:弹窗尺寸由 .detail-panel-right rect 自动决定,
- // 用户手动缩放反而会破坏与父面板的尺寸联动。
- resizable: false,
- data: { intersectionId: this.getIntersectionId() },
- };
- const DESIGN_WIDTH = 1920;
- const scale = window.innerWidth / DESIGN_WIDTH;
- const root = this.$el && this.$el.closest && this.$el.closest('.crossing-detail-panel');
- const rightPanel = root && root.querySelector(':scope > .detail-panel-right');
- if (rightPanel && scale > 0) {
- const rect = rightPanel.getBoundingClientRect();
- if (rect.width > 0 && rect.height > 0) {
- cfg.center = false;
- cfg.position = { x: rect.left / scale, y: rect.top / scale };
- cfg.width = Math.round(rect.width / scale);
- cfg.height = Math.round(rect.height / scale);
- }
- }
- // 兜底:找不到右侧面板(独立使用场景)则居中 620×360
- if (cfg.width == null) {
- cfg.width = 620;
- cfg.height = 360;
- }
- this.dialogManager.openDialog(cfg);
- },
- closeDetectorDialog() {
- if (!this.dialogManager || typeof this.dialogManager.closeDialog !== 'function') return;
- this.dialogManager.closeDialog(`detector-monitor-${this._uid}`);
- },
- /** 摄像头视频弹窗:每个方向一个独立弹窗(id 含 dir + _uid),最多 4 个并存。
- * 默认按 2×2 网格摆在 .detail-panel-right 区域内(N→左上 / E→右上 / W→左下 / S→右下),
- * 跟随 multi-view 布局变化重排,但用户拖拽/缩放/下拉切换都保留不重置。 */
- openCameraDialog(dir) {
- if (!this.dialogManager || typeof this.dialogManager.openDialog !== 'function') return;
- const DIR_LABEL = { N: '北进口', E: '东进口', S: '南进口', W: '西进口' };
- const cfg = {
- id: `camera-video-${this._uid}-${dir}`,
- title: `摄像头-${DIR_LABEL[dir] || dir}`,
- component: 'CameraVideoDialog',
- noPadding: true, // 关掉 SmartDialog 默认 padding,组件内自管
- resizable: true,
- draggable: true,
- // 调小弹窗最小尺寸,allow multi-view 4 宫格里的小弹窗不被卡死在 200×150
- minWidth: 80,
- minHeight: 60,
- data: {
- intersectionId: this.getIntersectionId(),
- initialDir: dir,
- cameras: (this.mapData && this.mapData.cameras) || [],
- },
- };
- const rect = this._calcCameraDialogRect(dir);
- if (rect) {
- cfg.center = false;
- cfg.position = rect.position;
- cfg.width = rect.width;
- cfg.height = rect.height;
- } else {
- cfg.width = 380;
- cfg.height = 280;
- }
- this.dialogManager.openDialog(cfg);
- },
- closeAllCameraDialogs() {
- if (!this.dialogManager || typeof this.dialogManager.closeDialog !== 'function') return;
- ['N', 'E', 'S', 'W'].forEach(dir => {
- this.dialogManager.closeDialog(`camera-video-${this._uid}-${dir}`);
- });
- },
- /** 仅更新位置/尺寸(不传 data),避免重置用户在弹窗内的下拉选择 */
- repositionOpenCameraDialogs() {
- if (!this.dialogManager || typeof this.dialogManager.getDialogs !== 'function') return;
- const dialogs = this.dialogManager.getDialogs();
- const prefix = `camera-video-${this._uid}-`;
- dialogs.forEach(d => {
- const idStr = String(d.id);
- if (!idStr.startsWith(prefix) || !d.visible) return;
- const dir = idStr.slice(prefix.length);
- const rect = this._calcCameraDialogRect(dir);
- if (!rect) return;
- this.dialogManager.openDialog({
- id: idStr,
- center: false,
- position: rect.position,
- width: rect.width,
- height: rect.height,
- });
- });
- },
- /** 把 dir 映射成 .detail-panel-right 内的 2×2 单元格,返回设计坐标系下的 position/width/height。
- * N→左上, E→右上, W→左下, S→右下,与画面方位一致。 */
- _calcCameraDialogRect(dir) {
- const DESIGN_WIDTH = 1920;
- const scale = window.innerWidth / DESIGN_WIDTH;
- const root = this.$el && this.$el.closest && this.$el.closest('.crossing-detail-panel');
- const rightPanel = root && root.querySelector(':scope > .detail-panel-right');
- if (!(rightPanel && scale > 0)) return null;
- const rect = rightPanel.getBoundingClientRect();
- if (!(rect.width > 0 && rect.height > 0)) return null;
- const DIR_TO_CELL = {
- N: { col: 0, row: 0 },
- E: { col: 1, row: 0 },
- W: { col: 0, row: 1 },
- S: { col: 1, row: 1 },
- };
- const GAP = 4; // 单元格间距(设计像素)
- // 单元尺寸严格按右侧面板的 1/2 切分,不加最小兜底
- // —— 多窗口下面板可能只剩 ~300×200 设计像素,加 MIN 反而让 4 弹窗溢出
- const wDesign = rect.width / scale;
- const hDesign = rect.height / scale;
- const cellW = Math.max(40, Math.floor((wDesign - GAP) / 2));
- const cellH = Math.max(40, Math.floor((hDesign - GAP) / 2));
- const cell = DIR_TO_CELL[dir] || { col: 0, row: 0 };
- return {
- position: {
- x: rect.left / scale + cell.col * (cellW + GAP),
- y: rect.top / scale + cell.row * (cellH + GAP),
- },
- width: cellW,
- height: cellH,
- };
- },
- createCameraIcon(type, x, y, dir, armRotation) {
- // 反向旋转:让 4 个方向的摄像头图标都正立显示(与检测器图标一致),
- // 否则 E/S/W 会随 arm 旋转 90/180/270,倒立或横向的 枪机 视觉上像球机/检测器。
- const group = new Konva.Group({ x, y, rotation: -armRotation });
- const HIT = 18; // 加大点击命中区,避免只有线条上能点
- if (type === 1) {
- group.add(new Konva.Line({ points: [-16, -30, 16, -30], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round', hitStrokeWidth: HIT }));
- group.add(new Konva.Line({ points: [0, -30, 0, -10], stroke: this.C.BLUE, strokeWidth: 2.5, hitStrokeWidth: HIT }));
- 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, hitStrokeWidth: HIT }));
- body.add(new Konva.Rect({ x: 16, y: -4, width: 6, height: 8, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 1, hitStrokeWidth: HIT }));
- 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, hitStrokeWidth: HIT }));
- group.add(new Konva.Circle({ x: 0, y: -12, radius: 5, stroke: this.C.BLUE, strokeWidth: 2.5, hitStrokeWidth: HIT }));
- group.add(new Konva.Line({ points: [-10, 0, 10, 0], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round', hitStrokeWidth: HIT }));
- group.add(new Konva.Line({ points: [0, 0, 0, 6], stroke: this.C.BLUE, strokeWidth: 2.5, hitStrokeWidth: HIT }));
- }
- group.listening(true);
- const stage = () => this.stage && this.stage.container();
- group.on('mouseenter', () => { const c = stage(); if (c) c.style.cursor = 'pointer'; });
- group.on('mouseleave', () => { const c = stage(); if (c) c.style.cursor = 'default'; });
- group.on('click tap', () => this.openCameraDialog(dir));
- 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 armRotation = armNode.rotation() || 0;
- const cam = this.createCameraIcon(armData.cameraType, -80, -190, dir, armRotation);
- 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.renderDetectors();
- this.applyDisplayMode();
- 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; /* 图层垫底 */
- }
- .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;
- }
- </style>
|