IntersectionMapVideos.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783
  1. <template>
  2. <div class="map-wrapper" ref="wrapper">
  3. <div class="konva-container" ref="konvaContainer"></div>
  4. <div v-show="toggleVisible" class="display-mode-toggle" :style="toggleStyle">
  5. <SegmentedRadio v-model="displayMode" :options="displayModeOptions" size="auto" />
  6. </div>
  7. </div>
  8. </template>
  9. <script>
  10. import Konva from 'konva';
  11. import SegmentedRadio from '@/components/ui/SegmentedRadio.vue';
  12. import { apiGetDetectorMonitorData } from '@/api';
  13. // 检测器现挂在 armNode 内部,位置使用 arm-local 坐标(与 createArrowIcon 一致),
  14. // y=-190 对应原摄像机锚点(路口前方约一倍车道处的虚拟检测门架),由 arm 旋转自动定位到四个方向。
  15. // 方向箭头 SVG 路径映射
  16. // 所有方向臂共用同一套图标(以N方向/向下驶入为基准),由 createRoadArm 的 rotation 自动旋转
  17. // R(右转)用左转图标水平翻转
  18. const arrowSvgMap = {
  19. S: require('@/assets/images/svg/icon_straight_down.svg'),
  20. L: require('@/assets/images/svg/icon_turn_down_left.svg'),
  21. U: require('@/assets/images/svg/icon_turn_down_left_uturn.svg'),
  22. R: require('@/assets/images/svg/icon_turn_down_left.svg'), // 右转用左转图标,渲染时水平翻转
  23. };
  24. // SVG 原始文本映射(内联,避免 webpack loader 问题)
  25. const svgRawCache = {};
  26. // 首次加载时通过 Image → Canvas 获取不到 SVG 内容,所以直接用内联方式
  27. // 从 require 得到的 URL(可能是 base64 或路径)加载图片
  28. const imgCache = {};
  29. function loadSvgImage(svgUrl, fillColor) {
  30. const cacheKey = svgUrl + '|' + fillColor;
  31. if (imgCache[cacheKey]) return Promise.resolve(imgCache[cacheKey]);
  32. // 如果是 base64 data URL,解码后替换颜色
  33. if (svgUrl.startsWith('data:image/svg+xml;base64,')) {
  34. const base64 = svgUrl.split(',')[1];
  35. let svgText = atob(base64);
  36. svgText = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
  37. const encoded = btoa(svgText);
  38. const dataUrl = 'data:image/svg+xml;base64,' + encoded;
  39. return new Promise(resolve => {
  40. const img = new Image();
  41. img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
  42. img.src = dataUrl;
  43. });
  44. }
  45. // 普通 URL,fetch 后替换颜色
  46. return fetch(svgUrl)
  47. .then(r => r.text())
  48. .then(svgText => {
  49. const colored = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
  50. const blob = new Blob([colored], { type: 'image/svg+xml' });
  51. const url = URL.createObjectURL(blob);
  52. return new Promise(resolve => {
  53. const img = new Image();
  54. img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
  55. img.src = url;
  56. });
  57. });
  58. }
  59. export default {
  60. name: 'IntersectionMapVideos',
  61. components: {
  62. SegmentedRadio,
  63. },
  64. inject: {
  65. // 用 default 兜底:IntersectionMapVideos 也可能被独立测试或其它非 DashboardLayout 父级使用
  66. dialogManager: { default: null },
  67. },
  68. props: {
  69. // 路口数字孪生数据
  70. mapData: {
  71. type: Object,
  72. required: true
  73. },
  74. },
  75. data() {
  76. return {
  77. stage: null,
  78. layer: null,
  79. armsNodes: {},
  80. panelNodes: {},
  81. resizeObserver: null,
  82. C: {
  83. BG: '#212842', ROAD: '#3d3938', YELLOW: '#D9A73D', WHITE: '#E0E0E0',
  84. SIGNAL_RED: '#FF5252', SIGNAL_GREEN: '#8DF582',
  85. PANEL_BG: 'rgba(30, 30, 40, 0.85)', BLUE: '#448AFF'
  86. },
  87. sizeConfig: {
  88. stageSize: 900,
  89. laneWidth: 40,
  90. halfRoad: 160,
  91. roadWidth: 320,
  92. armLength: 350
  93. },
  94. stageWidth: 900, // 当前画布缩放后的真实宽度
  95. stageHeight: 900, // 当前画布缩放后的真实高度
  96. // 视频/检测器 切换
  97. displayMode: 'video',
  98. displayModeOptions: [
  99. { label: '视频', value: 'video' },
  100. { label: '检测器', value: 'detector' },
  101. ],
  102. toggleVisible: true, // wrapper < 240px 时彻底隐藏(窗口太小放不下)
  103. toggleScale: 1, // 自适应缩放:wrapper 宽度低于全尺寸阈值时按比例缩小
  104. // 检测器节点(直接挂在 layer 上,不进 armNode;位置见 DETECTOR_POSITIONS)
  105. // 注意:detectorBase / detectorTimer 必须不带 `_` 前缀,否则 Vue 2 不会代理到 this(导致 undefined 错误)
  106. detectorNodes: { N: null, E: null, S: null, W: null },
  107. detectorBase: { N: null, E: null, S: null, W: null },
  108. detectorTimer: null,
  109. };
  110. },
  111. computed: {
  112. toggleStyle() {
  113. return {
  114. transform: `scale(${this.toggleScale})`,
  115. transformOrigin: 'top right',
  116. };
  117. },
  118. },
  119. mounted() {
  120. this.initKonvaStage();
  121. if (this.mapData && Object.keys(this.mapData).length > 0) {
  122. this.renderStaticConfig();
  123. this.updateDynamicSignals();
  124. }
  125. this.initResizeObserver();
  126. },
  127. beforeDestroy() {
  128. this.stopDetectorPolling();
  129. this.closeDetectorDialog();
  130. this.closeAllCameraDialogs();
  131. if (this.resizeObserver) this.resizeObserver.disconnect();
  132. if (this.stage) this.stage.destroy();
  133. },
  134. watch: {
  135. mapData: {
  136. handler(newData, oldData) {
  137. if (!newData) return;
  138. if (!oldData || JSON.stringify(newData.armsConfig) !== JSON.stringify(oldData.armsConfig)) {
  139. this.renderStaticConfig();
  140. }
  141. this.updateDynamicSignals();
  142. },
  143. deep: true
  144. },
  145. displayMode() {
  146. this.applyDisplayMode();
  147. },
  148. },
  149. methods: {
  150. // ================= 以下为原有的 Konva 绘制逻辑,完全保持不变 =================
  151. initKonvaStage() {
  152. const { stageSize, halfRoad, roadWidth } = this.sizeConfig;
  153. const center = stageSize / 2;
  154. this.stage = new Konva.Stage({
  155. container: this.$refs.konvaContainer,
  156. width: stageSize,
  157. height: stageSize
  158. });
  159. this.layer = new Konva.Layer();
  160. this.stage.add(this.layer);
  161. this.layer.add(new Konva.Rect({ width: stageSize, height: stageSize, fill: this.C.BG }));
  162. this.layer.add(new Konva.Rect({ x: center - halfRoad, y: center - halfRoad, width: roadWidth, height: roadWidth, fill: this.C.ROAD }));
  163. this.armsNodes = {
  164. N: this.createRoadArm(center, center - halfRoad, 0),
  165. E: this.createRoadArm(center + halfRoad, center, 90),
  166. S: this.createRoadArm(center, center + halfRoad, 180),
  167. W: this.createRoadArm(center - halfRoad, center, 270)
  168. };
  169. Object.values(this.armsNodes).forEach(arm => this.layer.add(arm));
  170. this.createCenterPanel(center);
  171. this.layer.draw();
  172. },
  173. initResizeObserver() {
  174. this.resizeObserver = new ResizeObserver(() => {
  175. window.requestAnimationFrame(() => {
  176. this.handleResize();
  177. });
  178. });
  179. if (this.$refs.wrapper) {
  180. this.resizeObserver.observe(this.$refs.wrapper);
  181. }
  182. },
  183. handleResize() {
  184. if (!this.stage || !this.$refs.wrapper) return;
  185. const containerWidth = this.$refs.wrapper.clientWidth;
  186. const containerHeight = this.$refs.wrapper.clientHeight;
  187. if (containerWidth === 0 || containerHeight === 0) return;
  188. const scaleX = containerWidth / this.sizeConfig.stageSize;
  189. const scaleY = containerHeight / this.sizeConfig.stageSize;
  190. const scale = Math.min(scaleX, scaleY);
  191. // 【核心修改】:记录缩放后的实际物理尺寸,供视频遮罩层使用
  192. this.stageWidth = this.sizeConfig.stageSize * scale;
  193. this.stageHeight = this.sizeConfig.stageSize * scale;
  194. this.stage.width(this.stageWidth);
  195. this.stage.height(this.stageHeight);
  196. this.stage.scale({ x: scale, y: scale });
  197. // 自适应:wrapper >= 600 时全尺寸;240~600 之间线性缩放(最小 0.55);< 240 直接隐藏
  198. if (containerWidth < 240) {
  199. this.toggleVisible = false;
  200. } else {
  201. this.toggleVisible = true;
  202. this.toggleScale = Math.min(1, Math.max(0.55, containerWidth / 600));
  203. }
  204. // multi-view 布局变化(增删面板/expand 切换)会让 .detail-panel-right 重排,
  205. // 检测器弹窗若已开着,跟随重新定位+尺寸。重入 openDialog 走 existing 分支只更新位置尺寸。
  206. if (this.displayMode === 'detector') {
  207. this.openDetectorDialog();
  208. }
  209. // 已打开的摄像头视频弹窗按 2×2 网格重排(保留用户的下拉/拖拽不重置)
  210. this.repositionOpenCameraDialogs();
  211. },
  212. createRoadArm(x, y, rotation) {
  213. const { halfRoad, roadWidth, laneWidth } = this.sizeConfig;
  214. const group = new Konva.Group({ x, y, rotation });
  215. group.add(new Konva.Rect({ x: -halfRoad, y: -350, width: roadWidth, height: 350, fill: this.C.ROAD }));
  216. group.add(new Konva.Line({ points: [0, -350, 0, -35], stroke: this.C.YELLOW, strokeWidth: 3 }));
  217. group.add(new Konva.Path({ data: `M -160 -350 L -160 -30 Q -160 0 -180 0`, stroke: this.C.YELLOW, strokeWidth: 3 }));
  218. group.add(new Konva.Path({ data: `M 160 -350 L 160 -30 Q 160 0 180 0`, stroke: this.C.YELLOW, strokeWidth: 3 }));
  219. group.add(new Konva.Line({ points: [-160, -35, 0, -35], stroke: this.C.WHITE, strokeWidth: 4 }));
  220. for (let i = 1; i < 4; i++) {
  221. let ox = i * laneWidth;
  222. group.add(new Konva.Line({ points: [-ox, -35, -ox, -120], stroke: this.C.WHITE, strokeWidth: 2 }));
  223. group.add(new Konva.Line({ points: [-ox, -120, -ox, -350], stroke: this.C.WHITE, strokeWidth: 2, dash: [15, 15] }));
  224. group.add(new Konva.Line({ points: [ox, -35, ox, -350], stroke: this.C.WHITE, strokeWidth: 2, dash: [15, 15] }));
  225. }
  226. const lightGroup = new Konva.Group();
  227. const rectOpts = { y: -16, width: 8, height: 24, cornerRadius: 2, offsetX: 4, offsetY: 12 };
  228. for (let lx = -148; lx <= -20; lx += 16) lightGroup.add(new Konva.Rect({ x: lx, ...rectOpts }));
  229. for (let rx = 20; rx <= 148; rx += 16) lightGroup.add(new Konva.Rect({ x: rx, ...rectOpts }));
  230. group.add(lightGroup);
  231. group.lightGroup = lightGroup;
  232. group.arrowNodes = { 0: null, 1: null, 2: null, 3: null };
  233. group.cameraNode = null;
  234. return group;
  235. },
  236. createCenterPanel(center) {
  237. const panelGroup = new Konva.Group({ x: center - 80, y: center - 45 });
  238. panelGroup.add(new Konva.Rect({ width: 160, height: 90, fill: this.C.PANEL_BG, cornerRadius: 8 }));
  239. const labelFont = { fontSize: 18, fontFamily: 'monospace', fontStyle: 'bold', fill: this.C.WHITE };
  240. const valueFont = { fontSize: 28, fontFamily: 'monospace', fontStyle: 'bold' };
  241. this.panelNodes.nsLabel = new Konva.Text({ ...labelFont, x: 15, y: 22, text: '相位-:' });
  242. this.panelNodes.nsVal = new Konva.Text({ ...valueFont, x: 90, y: 15, text: '--', fill: this.C.SIGNAL_GREEN });
  243. this.panelNodes.ewLabel = new Konva.Text({ ...labelFont, x: 15, y: 55, text: '相位-:' });
  244. this.panelNodes.ewVal = new Konva.Text({ ...valueFont, x: 90, y: 48, text: '--', fill: this.C.SIGNAL_GREEN });
  245. panelGroup.add(this.panelNodes.nsLabel, this.panelNodes.nsVal, this.panelNodes.ewLabel, this.panelNodes.ewVal);
  246. this.layer.add(panelGroup);
  247. },
  248. createArrowIcon(type, x, y, color = this.C.WHITE) {
  249. const maxH = 40; // 最大高度
  250. const group = new Konva.Group({ x, y });
  251. if (type === 'R') group.scaleX(-1);
  252. group._arrowMeta = { type };
  253. const svgUrl = arrowSvgMap[type];
  254. if (svgUrl) {
  255. loadSvgImage(svgUrl, color).then(imgObj => {
  256. // 按原始比例等比缩放,高度不超过 maxH
  257. const natW = imgObj.naturalWidth || imgObj.width;
  258. const natH = imgObj.naturalHeight || imgObj.height;
  259. const scale = Math.min(maxH / natH, 1);
  260. const w = Math.round(natW * scale);
  261. const h = Math.round(natH * scale);
  262. const konvaImg = new Konva.Image({
  263. image: imgObj,
  264. x: -w / 2,
  265. y: -h - 5,
  266. width: w,
  267. height: h,
  268. name: 'arrowImg'
  269. });
  270. group.add(konvaImg);
  271. if (this.layer) this.layer.draw();
  272. });
  273. }
  274. return group;
  275. },
  276. /** 检测器图标(球机造型)。放在进口道中线、徽章列上方;
  277. * `armRotation` 用来反向旋转,让所有方向的图标都朝上,与 arm 旋转无关。 */
  278. createDetectorIcon(armRotation) {
  279. const stroke = '#7fb6ff';
  280. const group = new Konva.Group({
  281. x: -80, y: -240,
  282. rotation: -armRotation,
  283. });
  284. group.add(new Konva.Rect({
  285. x: -18, y: -32, width: 36, height: 32,
  286. stroke, strokeWidth: 2.5, cornerRadius: 8,
  287. fill: 'rgba(58,127,209,0.2)',
  288. }));
  289. group.add(new Konva.Circle({ x: 0, y: -16, radius: 7, stroke, strokeWidth: 2.5 }));
  290. group.add(new Konva.Line({ points: [-13, 0, 13, 0], stroke, strokeWidth: 2.5, lineCap: 'round' }));
  291. group.add(new Konva.Line({ points: [0, 0, 0, 8], stroke, strokeWidth: 2.5 }));
  292. return group;
  293. },
  294. /** 检测器编号徽章(小圆 + 数字)。文字反向旋转,保证四个方向都朝上。 */
  295. createDetectorBadge(lx, ly, num, armRotation) {
  296. const stroke = '#7fb6ff';
  297. const fill = '#3a7fd1';
  298. const group = new Konva.Group();
  299. group.add(new Konva.Circle({ x: lx, y: ly, radius: 13, fill, stroke, strokeWidth: 1.5 }));
  300. group.add(new Konva.Text({
  301. x: lx, y: ly,
  302. offsetX: 13, offsetY: 9, // 把旋转中心从左上角搬到 (lx, ly)
  303. width: 26, align: 'center',
  304. text: String(num), fontSize: 16, fontStyle: 'bold', fill: '#fff',
  305. rotation: -armRotation,
  306. }));
  307. return group;
  308. },
  309. /** (重)创建四个方向的检测器组:图标 + 每条车道一个编号徽章。
  310. * 组挂在 armNode 内,arm 旋转/平移自动定位;图标和数字通过反向旋转保持朝上。 */
  311. renderDetectors() {
  312. const cfg = (this.mapData && this.mapData.armsConfig) || {};
  313. const { laneWidth } = this.sizeConfig;
  314. const detY = -190; // 与原 cameraNode 锚点一致
  315. // 全路口连续编号:N→E→S→W 顺时针累加,每条车道一个号;
  316. // 每个方向内按司机视角"左→右"(arm-local 最外侧 → 最内侧)编号。
  317. let badgeNum = 1;
  318. ['N', 'E', 'S', 'W'].forEach(dir => {
  319. const armNode = this.armsNodes[dir];
  320. if (!armNode) return;
  321. // 清理旧检测器组
  322. if (armNode.detectorGroup) {
  323. armNode.detectorGroup.destroy();
  324. armNode.detectorGroup = null;
  325. }
  326. this.detectorNodes[dir] = null;
  327. const armData = cfg[dir];
  328. if (!armData) return;
  329. const lanes = armData.lanes || [];
  330. const armRotation = armNode.rotation() || 0;
  331. const detGroup = new Konva.Group();
  332. // 检测器图标(一方向一个,徽章列上方)
  333. detGroup.add(this.createDetectorIcon(armRotation));
  334. // 车道徽章
  335. for (let index = lanes.length - 1; index >= 0; index--) {
  336. const lx = -20 - index * laneWidth;
  337. detGroup.add(this.createDetectorBadge(lx, detY, badgeNum++, armRotation));
  338. }
  339. detGroup.visible(this.displayMode === 'detector');
  340. armNode.add(detGroup);
  341. armNode.detectorGroup = detGroup;
  342. this.detectorNodes[dir] = detGroup;
  343. });
  344. this.syncDetectorBase();
  345. this.updateDetectorTexts();
  346. },
  347. /** 把 mapData 的检测器初值缓存为波动中心;显示值初始化为基础值 */
  348. syncDetectorBase() {
  349. const cfg = (this.mapData && this.mapData.armsConfig) || {};
  350. ['N', 'E', 'S', 'W'].forEach(dir => {
  351. const det = cfg[dir] && cfg[dir].detector;
  352. this.detectorBase[dir] = det ? { flow: det.flow, occupancy: det.occupancy } : null;
  353. });
  354. },
  355. /** 用 detectorBase 当前值刷一次显示文本(当前画布版未绘制流量/占有率文字,
  356. * 保留方法以便未来恢复显示;polling 仍在跑,detectorBase 持续更新供 DetectorTable 等下游消费) */
  357. updateDetectorTexts() {
  358. ['N', 'E', 'S', 'W'].forEach(dir => {
  359. const base = this.detectorBase[dir];
  360. const node = this.detectorNodes[dir];
  361. if (!base || !node) return;
  362. if (node.flowText) node.flowText.text(`流量:${base.flow}`);
  363. if (node.occText) node.occText.text(`占有率:${base.occupancy}%`);
  364. });
  365. if (this.layer) this.layer.batchDraw();
  366. },
  367. /** 同源轮询:拉 apiGetDetectorMonitorData,把 armsDetector 写回 detectorBase 并刷新文字。
  368. * 与 DetectorTable 用同一接口,画布每 5s 跳到与表格同源的当前桶值。 */
  369. async pollDetectorData() {
  370. try {
  371. const id = this.getIntersectionId();
  372. const res = await apiGetDetectorMonitorData(id);
  373. const arms = res && res.armsDetector;
  374. if (!arms) return;
  375. ['N', 'E', 'S', 'W'].forEach(dir => {
  376. const d = arms[dir];
  377. if (d) this.detectorBase[dir] = { flow: d.flow, occupancy: d.occupancy };
  378. });
  379. this.updateDetectorTexts();
  380. } catch (e) {
  381. console.warn('[IntersectionMapVideos] poll detector failed:', e);
  382. }
  383. },
  384. startDetectorPolling() {
  385. if (this.detectorTimer) return;
  386. this.pollDetectorData(); // 进入检测器模式立即拉一次,避免空 5s 等待
  387. this.detectorTimer = setInterval(this.pollDetectorData, 5000);
  388. },
  389. stopDetectorPolling() {
  390. if (this.detectorTimer) {
  391. clearInterval(this.detectorTimer);
  392. this.detectorTimer = null;
  393. }
  394. },
  395. /** 切换 video/detector:用 visible() 而不是 destroy 重建,开销最小 */
  396. applyDisplayMode() {
  397. const showDetector = this.displayMode === 'detector';
  398. ['N', 'E', 'S', 'W'].forEach(dir => {
  399. const arm = this.armsNodes[dir];
  400. if (arm && arm.cameraNode) arm.cameraNode.visible(!showDetector);
  401. if (this.detectorNodes[dir]) this.detectorNodes[dir].visible(showDetector);
  402. });
  403. if (this.layer) this.layer.batchDraw();
  404. if (showDetector) {
  405. this.startDetectorPolling();
  406. this.openDetectorDialog();
  407. this.closeAllCameraDialogs(); // 切到检测器:关掉所有摄像头视频弹窗
  408. } else {
  409. this.stopDetectorPolling();
  410. this.closeDetectorDialog(); // 切回视频:关检测器弹窗(视频弹窗按需点开,无需自动开)
  411. }
  412. },
  413. /** 从 mapData 推导出当前路口 id(detectors / cameras 数组里都带 intersectionId) */
  414. getIntersectionId() {
  415. const m = this.mapData || {};
  416. if (Array.isArray(m.detectors) && m.detectors.length) return m.detectors[0].intersectionId || '';
  417. if (Array.isArray(m.cameras) && m.cameras.length) return m.cameras[0].intersectionId || '';
  418. return '';
  419. },
  420. /** 检测器模式下打开弹窗:默认贴到本路口详情面板右侧(控制方式区域)覆盖显示。
  421. * 无法定位时回退到居中默认尺寸。SmartDialog 用 1920 设计宽度做缩放,所以这里把
  422. * 实际像素 rect 换算回设计坐标传过去。 */
  423. openDetectorDialog() {
  424. if (!this.dialogManager || typeof this.dialogManager.openDialog !== 'function') return;
  425. const cfg = {
  426. id: `detector-monitor-${this._uid}`,
  427. title: '检测器运行数据监视',
  428. component: 'DetectorTable',
  429. noPadding: false,
  430. // 关闭右下角缩放手柄:弹窗尺寸由 .detail-panel-right rect 自动决定,
  431. // 用户手动缩放反而会破坏与父面板的尺寸联动。
  432. resizable: false,
  433. data: { intersectionId: this.getIntersectionId() },
  434. };
  435. const DESIGN_WIDTH = 1920;
  436. const scale = window.innerWidth / DESIGN_WIDTH;
  437. const root = this.$el && this.$el.closest && this.$el.closest('.crossing-detail-panel');
  438. const rightPanel = root && root.querySelector(':scope > .detail-panel-right');
  439. if (rightPanel && scale > 0) {
  440. const rect = rightPanel.getBoundingClientRect();
  441. if (rect.width > 0 && rect.height > 0) {
  442. cfg.center = false;
  443. cfg.position = { x: rect.left / scale, y: rect.top / scale };
  444. cfg.width = Math.round(rect.width / scale);
  445. cfg.height = Math.round(rect.height / scale);
  446. }
  447. }
  448. // 兜底:找不到右侧面板(独立使用场景)则居中 620×360
  449. if (cfg.width == null) {
  450. cfg.width = 620;
  451. cfg.height = 360;
  452. }
  453. this.dialogManager.openDialog(cfg);
  454. },
  455. closeDetectorDialog() {
  456. if (!this.dialogManager || typeof this.dialogManager.closeDialog !== 'function') return;
  457. this.dialogManager.closeDialog(`detector-monitor-${this._uid}`);
  458. },
  459. /** 摄像头视频弹窗:每个方向一个独立弹窗(id 含 dir + _uid),最多 4 个并存。
  460. * 默认按 2×2 网格摆在 .detail-panel-right 区域内(N→左上 / E→右上 / W→左下 / S→右下),
  461. * 跟随 multi-view 布局变化重排,但用户拖拽/缩放/下拉切换都保留不重置。 */
  462. openCameraDialog(dir) {
  463. if (!this.dialogManager || typeof this.dialogManager.openDialog !== 'function') return;
  464. const DIR_LABEL = { N: '北进口', E: '东进口', S: '南进口', W: '西进口' };
  465. const cfg = {
  466. id: `camera-video-${this._uid}-${dir}`,
  467. title: `摄像头-${DIR_LABEL[dir] || dir}`,
  468. component: 'CameraVideoDialog',
  469. noPadding: true, // 关掉 SmartDialog 默认 padding,组件内自管
  470. resizable: true,
  471. draggable: true,
  472. // 调小弹窗最小尺寸,allow multi-view 4 宫格里的小弹窗不被卡死在 200×150
  473. minWidth: 80,
  474. minHeight: 60,
  475. data: {
  476. intersectionId: this.getIntersectionId(),
  477. initialDir: dir,
  478. cameras: (this.mapData && this.mapData.cameras) || [],
  479. },
  480. };
  481. const rect = this._calcCameraDialogRect(dir);
  482. if (rect) {
  483. cfg.center = false;
  484. cfg.position = rect.position;
  485. cfg.width = rect.width;
  486. cfg.height = rect.height;
  487. } else {
  488. cfg.width = 380;
  489. cfg.height = 280;
  490. }
  491. this.dialogManager.openDialog(cfg);
  492. },
  493. closeAllCameraDialogs() {
  494. if (!this.dialogManager || typeof this.dialogManager.closeDialog !== 'function') return;
  495. ['N', 'E', 'S', 'W'].forEach(dir => {
  496. this.dialogManager.closeDialog(`camera-video-${this._uid}-${dir}`);
  497. });
  498. },
  499. /** 仅更新位置/尺寸(不传 data),避免重置用户在弹窗内的下拉选择 */
  500. repositionOpenCameraDialogs() {
  501. if (!this.dialogManager || typeof this.dialogManager.getDialogs !== 'function') return;
  502. const dialogs = this.dialogManager.getDialogs();
  503. const prefix = `camera-video-${this._uid}-`;
  504. dialogs.forEach(d => {
  505. const idStr = String(d.id);
  506. if (!idStr.startsWith(prefix) || !d.visible) return;
  507. const dir = idStr.slice(prefix.length);
  508. const rect = this._calcCameraDialogRect(dir);
  509. if (!rect) return;
  510. this.dialogManager.openDialog({
  511. id: idStr,
  512. center: false,
  513. position: rect.position,
  514. width: rect.width,
  515. height: rect.height,
  516. });
  517. });
  518. },
  519. /** 把 dir 映射成 .detail-panel-right 内的 2×2 单元格,返回设计坐标系下的 position/width/height。
  520. * N→左上, E→右上, W→左下, S→右下,与画面方位一致。 */
  521. _calcCameraDialogRect(dir) {
  522. const DESIGN_WIDTH = 1920;
  523. const scale = window.innerWidth / DESIGN_WIDTH;
  524. const root = this.$el && this.$el.closest && this.$el.closest('.crossing-detail-panel');
  525. const rightPanel = root && root.querySelector(':scope > .detail-panel-right');
  526. if (!(rightPanel && scale > 0)) return null;
  527. const rect = rightPanel.getBoundingClientRect();
  528. if (!(rect.width > 0 && rect.height > 0)) return null;
  529. const DIR_TO_CELL = {
  530. N: { col: 0, row: 0 },
  531. E: { col: 1, row: 0 },
  532. W: { col: 0, row: 1 },
  533. S: { col: 1, row: 1 },
  534. };
  535. const GAP = 4; // 单元格间距(设计像素)
  536. // 单元尺寸严格按右侧面板的 1/2 切分,不加最小兜底
  537. // —— 多窗口下面板可能只剩 ~300×200 设计像素,加 MIN 反而让 4 弹窗溢出
  538. const wDesign = rect.width / scale;
  539. const hDesign = rect.height / scale;
  540. const cellW = Math.max(40, Math.floor((wDesign - GAP) / 2));
  541. const cellH = Math.max(40, Math.floor((hDesign - GAP) / 2));
  542. const cell = DIR_TO_CELL[dir] || { col: 0, row: 0 };
  543. return {
  544. position: {
  545. x: rect.left / scale + cell.col * (cellW + GAP),
  546. y: rect.top / scale + cell.row * (cellH + GAP),
  547. },
  548. width: cellW,
  549. height: cellH,
  550. };
  551. },
  552. createCameraIcon(type, x, y, dir, armRotation) {
  553. // 反向旋转:让 4 个方向的摄像头图标都正立显示(与检测器图标一致),
  554. // 否则 E/S/W 会随 arm 旋转 90/180/270,倒立或横向的 枪机 视觉上像球机/检测器。
  555. const group = new Konva.Group({ x, y, rotation: -armRotation });
  556. const HIT = 18; // 加大点击命中区,避免只有线条上能点
  557. if (type === 1) {
  558. group.add(new Konva.Line({ points: [-16, -30, 16, -30], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round', hitStrokeWidth: HIT }));
  559. group.add(new Konva.Line({ points: [0, -30, 0, -10], stroke: this.C.BLUE, strokeWidth: 2.5, hitStrokeWidth: HIT }));
  560. const body = new Konva.Group({ y: -10, rotation: 15 });
  561. body.add(new Konva.Rect({ x: -16, y: -8, width: 32, height: 16, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 2, hitStrokeWidth: HIT }));
  562. body.add(new Konva.Rect({ x: 16, y: -4, width: 6, height: 8, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 1, hitStrokeWidth: HIT }));
  563. group.add(body);
  564. } else if (type === 2) {
  565. group.add(new Konva.Rect({ x: -14, y: -24, width: 28, height: 24, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 6, hitStrokeWidth: HIT }));
  566. group.add(new Konva.Circle({ x: 0, y: -12, radius: 5, stroke: this.C.BLUE, strokeWidth: 2.5, hitStrokeWidth: HIT }));
  567. group.add(new Konva.Line({ points: [-10, 0, 10, 0], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round', hitStrokeWidth: HIT }));
  568. group.add(new Konva.Line({ points: [0, 0, 0, 6], stroke: this.C.BLUE, strokeWidth: 2.5, hitStrokeWidth: HIT }));
  569. }
  570. group.listening(true);
  571. const stage = () => this.stage && this.stage.container();
  572. group.on('mouseenter', () => { const c = stage(); if (c) c.style.cursor = 'pointer'; });
  573. group.on('mouseleave', () => { const c = stage(); if (c) c.style.cursor = 'default'; });
  574. group.on('click tap', () => this.openCameraDialog(dir));
  575. return group;
  576. },
  577. renderStaticConfig() {
  578. const config = this.mapData.armsConfig;
  579. if (!config) return;
  580. Object.keys(config).forEach(dir => {
  581. const armData = config[dir];
  582. const armNode = this.armsNodes[dir];
  583. if (armNode.cameraNode) armNode.cameraNode.destroy();
  584. if (armData.cameraType > 0) {
  585. const armRotation = armNode.rotation() || 0;
  586. const cam = this.createCameraIcon(armData.cameraType, -80, -190, dir, armRotation);
  587. armNode.add(cam);
  588. armNode.cameraNode = cam;
  589. }
  590. armData.lanes.forEach((type, index) => {
  591. if (armNode.arrowNodes[index]) armNode.arrowNodes[index].destroy();
  592. if (type) {
  593. const lx = -20 - (index * this.sizeConfig.laneWidth);
  594. const arrow = this.createArrowIcon(type, lx, -80, this.C.WHITE);
  595. armNode.add(arrow);
  596. armNode.arrowNodes[index] = arrow;
  597. }
  598. });
  599. });
  600. this.renderDetectors();
  601. this.applyDisplayMode();
  602. this.layer.draw();
  603. },
  604. updateDynamicSignals() {
  605. const signals = this.mapData.signals;
  606. if (!signals) return;
  607. const config = this.mapData.armsConfig || {};
  608. const nsColor = signals.ns.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
  609. const ewColor = signals.ew.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
  610. const nsActiveTypes = signals.ns.activeArrowTypes || [];
  611. const ewActiveTypes = signals.ew.activeArrowTypes || [];
  612. const dyeArm = (dir, armNode, pedColor, vehicleColor, activeTypes) => {
  613. // 灯带颜色(人行道信号)
  614. armNode.lightGroup.getChildren().forEach(r => r.fill(pedColor));
  615. // 箭头按 lane type 用不同颜色的 SVG 替换
  616. const lanes = (config[dir] && config[dir].lanes) || [];
  617. Object.keys(armNode.arrowNodes).forEach(index => {
  618. const arr = armNode.arrowNodes[index];
  619. if (!arr) return;
  620. const laneType = lanes[index];
  621. const isActive = activeTypes.length > 0 && activeTypes.includes(laneType);
  622. const targetColor = isActive ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
  623. const meta = arr._arrowMeta;
  624. if (!meta) return;
  625. const svgUrl = arrowSvgMap[meta.type];
  626. if (!svgUrl) return;
  627. loadSvgImage(svgUrl, targetColor).then(imgObj => {
  628. const existing = arr.findOne('.arrowImg');
  629. if (existing) {
  630. existing.image(imgObj);
  631. } else {
  632. const maxH = 40;
  633. const natW = imgObj.naturalWidth || imgObj.width;
  634. const natH = imgObj.naturalHeight || imgObj.height;
  635. const scale = Math.min(maxH / natH, 1);
  636. const w = Math.round(natW * scale);
  637. const h = Math.round(natH * scale);
  638. arr.add(new Konva.Image({
  639. image: imgObj,
  640. x: -w / 2,
  641. y: -h - 5,
  642. width: w,
  643. height: h,
  644. name: 'arrowImg'
  645. }));
  646. }
  647. if (this.layer) this.layer.draw();
  648. });
  649. });
  650. };
  651. // 灯带代表人行道:P1/P3绿灯期间正常(车绿人红、车红人绿),其余时段人行道全红
  652. const pedAllRed = signals.pedAllRed || false;
  653. const nsPedColor = pedAllRed ? this.C.SIGNAL_RED : (signals.ns.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN);
  654. const ewPedColor = pedAllRed ? this.C.SIGNAL_RED : (signals.ew.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN);
  655. dyeArm('N', this.armsNodes.N, nsPedColor, nsColor, nsActiveTypes);
  656. dyeArm('S', this.armsNodes.S, nsPedColor, nsColor, nsActiveTypes);
  657. dyeArm('E', this.armsNodes.E, ewPedColor, ewColor, ewActiveTypes);
  658. dyeArm('W', this.armsNodes.W, ewPedColor, ewColor, ewActiveTypes);
  659. this.panelNodes.nsLabel.text(`${signals.ns.phaseName}:`);
  660. this.panelNodes.nsVal.text(signals.ns.time.toString().padStart(2, '0')).fill(nsColor);
  661. this.panelNodes.ewLabel.text(`${signals.ew.phaseName}:`);
  662. this.panelNodes.ewVal.text(signals.ew.time.toString().padStart(2, '0')).fill(ewColor);
  663. this.layer.draw();
  664. }
  665. }
  666. };
  667. </script>
  668. <style scoped>
  669. /* ================= 地图外层 ================= */
  670. .map-wrapper {
  671. width: 100%;
  672. height: 100%;
  673. overflow: hidden;
  674. background-color: #212842;
  675. position: relative; /* 核心:让子元素能在其内部绝对定位 */
  676. }
  677. .konva-container {
  678. position: absolute;
  679. top: 50%;
  680. left: 50%;
  681. transform: translate(-50%, -50%);
  682. z-index: 1; /* 图层垫底 */
  683. }
  684. .display-mode-toggle {
  685. position: absolute;
  686. top: 8px;
  687. right: 8px;
  688. z-index: 50; /* 高于 konva-container(z=1),避免被画布盖住 */
  689. width: 120px;
  690. font-size: 12px;
  691. background: rgba(5, 22, 45, 0.92); /* 不透明深底,多窗口下按钮压住画布也能看清 */
  692. border-radius: 4px;
  693. }
  694. .display-mode-toggle ::v-deep .radio-item {
  695. padding: 2px 6px;
  696. line-height: 1.3;
  697. }
  698. </style>