From dd42f554cc68c652bdab4fd27f5379f4cb83becb Mon Sep 17 00:00:00 2001 From: cuitw <1051735452@qq.com> Date: Tue, 24 Mar 2026 13:15:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E6=97=B6=E8=88=AA=E5=90=91=EF=BC=8C?= =?UTF-8?q?=E6=B5=8B=E9=87=8F=E6=B5=B7=E9=87=8C=EF=BC=8C=E6=A1=86=E9=80=89?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E7=A7=BB=E5=8A=A8=EF=BC=8C=E8=88=AA=E7=BA=BF?= =?UTF-8?q?=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-ui/src/views/cesiumMap/ContextMenu.vue | 80 +- .../src/views/cesiumMap/MapScreenDomLabels.vue | 122 ++ ruoyi-ui/src/views/cesiumMap/index.vue | 1968 +++++++++++++++----- ruoyi-ui/src/views/childRoom/LeftMenu.vue | 9 +- ruoyi-ui/src/views/childRoom/index.vue | 142 +- 5 files changed, 1846 insertions(+), 475 deletions(-) create mode 100644 ruoyi-ui/src/views/cesiumMap/MapScreenDomLabels.vue diff --git a/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue b/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue index 4f13a65..b7c5051 100644 --- a/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue +++ b/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue @@ -7,7 +7,17 @@ 切换选择 ({{ (pickIndex || 0) + 1 }}/{{ pickList.length }}) - @@ -568,6 +601,14 @@ export default { powerZoneVisible: { type: Boolean, default: true + }, + toolMode: { + type: String, + default: 'airspace' + }, + rangingDistanceUnit: { + type: String, + default: 'km' } }, data() { @@ -583,6 +624,7 @@ export default { showOpacityPicker: false, showFontSizePicker: false, showBearingTypeMenu: false, + showRangingUnitMenu: false, presetColors: [ '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF', '#FF6600', '#663399', '#999999', '#000000', '#FFFFFF', '#FF99CC', @@ -632,6 +674,10 @@ export default { this.$emit('delete') }, + handleDeleteBoxSelection() { + this.$emit('delete-box-selected-platforms') + }, + handleAdjustPosition() { this.$emit('adjust-airspace-position') }, @@ -690,6 +736,14 @@ export default { this.$emit('copy-route') }, + handleRouteSegmentSplit() { + this.$emit('route-segment-split') + }, + + handleRouteSegmentCopy() { + this.$emit('route-segment-copy') + }, + handleSingleRouteDeduction() { this.$emit('single-route-deduction', this.entityData.routeId) }, @@ -782,6 +836,8 @@ export default { this.showSizePicker = false this.showOpacityPicker = false this.showFontSizePicker = false + this.showBearingTypeMenu = false + this.showRangingUnitMenu = false this.showColorPickerFor = property } }, @@ -800,6 +856,8 @@ export default { this.showSizePicker = false this.showOpacityPicker = false this.showFontSizePicker = false + this.showBearingTypeMenu = false + this.showRangingUnitMenu = false this.showWidthPicker = true } }, @@ -894,6 +952,7 @@ export default { this.showSizePicker = false this.showOpacityPicker = false this.showFontSizePicker = false + this.showRangingUnitMenu = false } }, @@ -901,6 +960,25 @@ export default { // 选择方位角类型 this.$emit('update-property', 'bearingType', bearingType) this.showBearingTypeMenu = false + }, + + toggleRangingUnitMenu() { + this.showRangingUnitMenu = !this.showRangingUnitMenu + if (this.showRangingUnitMenu) { + this.showColorPickerFor = null + this.showWidthPicker = false + this.showSizePicker = false + this.showOpacityPicker = false + this.showFontSizePicker = false + this.showBearingTypeMenu = false + } + }, + + selectRangingUnit(unit) { + if (unit === 'km' || unit === 'nm') { + this.$emit('ranging-distance-unit', unit) + } + this.showRangingUnitMenu = false } } } diff --git a/ruoyi-ui/src/views/cesiumMap/MapScreenDomLabels.vue b/ruoyi-ui/src/views/cesiumMap/MapScreenDomLabels.vue new file mode 100644 index 0000000..15a0155 --- /dev/null +++ b/ruoyi-ui/src/views/cesiumMap/MapScreenDomLabels.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 7c13b26..8f0c190 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -29,6 +29,7 @@ top: hoverTooltip.position.y + 'px' }" /> + @@ -77,9 +84,50 @@ {{ platformIconRotateTip }} - -
- 点击地图放置复制航线,右键取消 + +
+
+ 已进入框选平台模式 +
+
+
+ 当前已选 {{ platformBoxSelectSelectedSummary.length }} 个 +
+
    +
  • + {{ idx + 1 }}. + {{ row.name }} +
  • +
+
+
+ 尚未框选到平台;在空白处拖拽矩形,框住图标即可加入列表。 +
+
+
+
+
+ + +
+ + +
+
+ +
@@ -324,6 +372,7 @@ import 'cesium/Build/Cesium/Widgets/widgets.css' import DrawingToolbar from './DrawingToolbar.vue' import MeasurementPanel from './MeasurementPanel.vue' import HoverTooltip from './HoverTooltip.vue' +import MapScreenDomLabels from './MapScreenDomLabels.vue' import ContextMenu from './ContextMenu.vue' import LocateDialog from './LocateDialog.vue' import RadiusDialog from '../dialogs/RadiusDialog.vue' @@ -407,6 +456,11 @@ export default { whiteboardEntities: { type: Array, default: () => [] + }, + /** 框选平台模式:空白处拖拽矩形多选房间平台图标,拖动任一选中项可整体平移 */ + platformBoxSelectMode: { + type: Boolean, + default: false } }, @@ -440,6 +494,10 @@ export default { content: '', position: { x: 0, y: 0 } }, + /** 地图场景内标签:屏幕坐标 DOM,由 postRender 同步位置(平台标牌、航点字、测距段标签等) */ + screenDomLabelItems: [], + /** 航线平台标牌数据:供 DOM 标牌与威力区扇形朝向等读取(原 labelEntity.labelDataCache) */ + platformScreenLabelState: {}, // 右键菜单 contextMenu: { visible: false, @@ -553,6 +611,8 @@ export default { }, /** 辅助线:水平/竖直约束,'none' | 'horizontal' | 'vertical' */ auxiliaryLineConstraint: 'none', + /** 测距模式距离显示:'km' 公里 | 'nm' 海里(1 海里 = 1852 m) */ + rangingDistanceUnit: 'km', // 鼠标经纬度 coordinatesText: '经度: --, 纬度: --', currentCoordinates: null, @@ -570,6 +630,8 @@ export default { powerZoneCenter: null, powerZoneCircleEntity: null, powerZoneCenterEntity: null, + /** 威力区绘制:点击定心时已占用 allEntities.id,确认半径时与圆心实体 id 对齐 */ + _pendingPowerZoneDataId: null, // 平台图标图形化编辑:拖拽移动、旋转、伸缩框 draggingPlatformIcon: null, rotatingPlatformIcon: null, @@ -612,6 +674,10 @@ export default { copyPreviewWaypoints: null, copyPreviewEntity: null, copyPreviewMouseCartesian: null, + /** 航段选取:依次点两个航点定范围 { routeId, waypoints, mode, firstIndex } */ + routeSegmentPickContext: null, + /** 航段移动预览元数据,与 copyPreviewWaypoints 同时使用 */ + routeSegmentPlaceMeta: null, // 在航点前/后增加航点:{ routeId, waypointIndex, mode: 'before'|'after', waypoints },预览折线实体 addWaypointContext: null, addWaypointSolidEntity: null, @@ -620,13 +686,27 @@ export default { airspacePositionEditContext: null, airspacePositionEditPreviewEntity: null, // 跑道形“椭圆”盘旋:两条直边 + 两段弧。边长默认 20km;弧转弯半径由航点转弯坡度+速度计算,不再单独存储 - DEFAULT_RACE_TRACK_EDGE_LENGTH_M: 20000 + DEFAULT_RACE_TRACK_EDGE_LENGTH_M: 20000, + /** 框选平台:矩形起点/当前点(画布坐标) */ + platformBoxSelectAnchor: null, + platformBoxSelectCurrent: null, + platformBoxSelectDragging: false, + platformMultiSelected: [], + _cameraDisabledForBoxSelect: false, + platformBoxSelectInnerRectStyle: { display: 'none' }, + pendingGroupDrag: false, + draggingPlatformGroup: false, + groupDragPickStart: null, + groupDragEntityCartesians: null, + groupDragScreenStart: null, + _cartScratchDelta: null } }, components: { DrawingToolbar, MeasurementPanel, HoverTooltip, + MapScreenDomLabels, ContextMenu, LocateDialog, RadiusDialog @@ -721,6 +801,20 @@ export default { }, 'contextMenu.visible'(val) { if (!val) this.clearAirspaceHighlight() + }, + platformBoxSelectMode(val) { + if (!val) { + this.exitPlatformBoxSelectMode() + } else if (this.selectedPlatformIcon) { + this.removeTransformHandles(this.selectedPlatformIcon) + this.selectedPlatformIcon = null + } + }, + rangingDistanceUnit() { + if (this.toolMode !== 'ranging' || !this.allEntities) return + this.allEntities.forEach((ed) => { + if (ed && ed.type === 'line') this.updateLineSegmentLabels(ed) + }) } }, computed: { @@ -767,6 +861,21 @@ export default { return c && c.powerZoneVisible === false ? false : true } return true + }, + platformBoxSelectOverlayVisible() { + return this.platformBoxSelectMode && this.platformBoxSelectDragging + }, + /** 框选模式下已选平台名称列表(用于 HUD 展示) */ + platformBoxSelectSelectedSummary() { + const list = this.platformMultiSelected || [] + return list.map((ed, i) => { + const name = + ed.label || + ed.name || + (ed.platform && (ed.platform.name || ed.platform.platformName)) || + '未命名平台' + return { name: String(name), key: ed.id || `platform-pick-${i}` } + }) } }, mounted() { @@ -837,26 +946,13 @@ export default { const iconSize = Math.max(48, Math.min(256, Number(this.editPlatformForm.iconSize) || 144)) const iconColor = this.editPlatformForm.iconColor || '#000000' - // 实时更新标牌外观 - const labelEntity = this.viewer.entities.getById(`route-platform-label-${routeId}`) - if (labelEntity) { - if (labelEntity.billboard) { - const data = labelEntity.labelDataCache || { name: '平台', altitude: 0, speed: 0, headingDeg: 0 } - const labelResult = this.createRoundedLabelCanvas({ - name: data.name, - altitude: data.altitude, - speed: data.speed, - heading: data.headingDeg, - fontSize, - fontColor - }) - labelEntity.billboard.image = new Cesium.ConstantProperty(labelResult.canvas) - labelEntity.billboard.scale = labelResult.scale - } else if (labelEntity.label) { - labelEntity.label.font = `${fontSize}px Microsoft YaHei` - labelEntity.label.fillColor = Cesium.Color.fromCssColorString(fontColor) - labelEntity.label.backgroundColor = Cesium.Color.fromCssColorString('rgba(255, 255, 255, 0.6)') - } + const st = this.platformScreenLabelState[routeId] + if (st) { + this.$set(this.platformScreenLabelState, routeId, { + ...st, + fontSize, + fontColor + }) } // 实时更新平台外观 @@ -878,25 +974,13 @@ export default { if (snapshot && snapshot.routeId && this.viewer && this.viewer.entities) { const { routeId, fontSize, fontColor, iconSize, iconColor } = snapshot - const labelEntity = this.viewer.entities.getById(`route-platform-label-${routeId}`) - if (labelEntity) { - if (labelEntity.billboard) { - const data = labelEntity.labelDataCache || { name: '平台', altitude: 0, speed: 0, headingDeg: 0 } - const labelResult = this.createRoundedLabelCanvas({ - name: data.name, - altitude: data.altitude, - speed: data.speed, - heading: data.headingDeg, - fontSize, - fontColor - }) - labelEntity.billboard.image = new Cesium.ConstantProperty(labelResult.canvas) - labelEntity.billboard.scale = labelResult.scale - } else if (labelEntity.label) { - labelEntity.label.font = `${fontSize}px Microsoft YaHei` - labelEntity.label.fillColor = Cesium.Color.fromCssColorString(fontColor) - labelEntity.label.backgroundColor = Cesium.Color.fromCssColorString('rgba(255, 255, 255, 0.6)') - } + const st = this.platformScreenLabelState[routeId] + if (st) { + this.$set(this.platformScreenLabelState, routeId, { + ...st, + fontSize, + fontColor + }) } const platformEntity = this.viewer.entities.getById(`route-platform-${routeId}`) @@ -1057,12 +1141,15 @@ export default { this.viewer.canvas.style.cursor = 'crosshair'; this.drawingHandler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas); window.addEventListener('contextmenu', this.preventContextMenu, true); - // 鼠标移动:更新实时光标位置并请求重绘,实现虚线预览跟随 + // 鼠标移动:更新实时光标位置并请求重绘,实现虚线预览跟随;显示至下一航点的距离与真方位 this.drawingHandler.setInputAction((movement) => { const newPosition = this.getClickPosition(movement.endPosition); if (newPosition) { this.activeCursorPosition = newPosition; + this.updateMissionRouteDrawingHoverTooltip(movement.endPosition); if (this.viewer.scene.requestRender) this.viewer.scene.requestRender(); + } else { + this.hoverTooltip.visible = false; } }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); // 左键点击逻辑 @@ -1202,6 +1289,90 @@ export default { if (this.missionPendingHold) return this.missionPendingHold.center; return this.drawingPoints[this.drawingPoints.length - 1]; }, + /** 航线绘制预览段起点(与虚线预览一致):平台关联绘制用最后录入点;普通绘制含待插入盘旋中心 */ + getMissionRouteDrawPreviewFromPosition() { + if (!this.drawingPoints || this.drawingPoints.length === 0) return null; + if (this.platformRouteDrawing) { + return this.drawingPoints[this.drawingPoints.length - 1]; + } + return this.getMissionRouteLastPosition(); + }, + /** 绘制航线时悬停提示:当前预览段起点 → 鼠标的距离与真方位 */ + updateMissionRouteDrawingHoverTooltip(screenPosition) { + const cursorPos = this.getClickPosition(screenPosition); + if (!cursorPos) { + this.hoverTooltip.visible = false; + return; + } + const from = this.getMissionRouteDrawPreviewFromPosition(); + if (!from) { + this.hoverTooltip.visible = false; + return; + } + const tempPositions = [from, cursorPos]; + const lengthM = this.calculateLineLength(tempPositions); + const bearing = this.calculateTrueBearing(tempPositions); + this.hoverTooltip = { + visible: true, + content: `${(lengthM / 1000).toFixed(1)}km,真方位 ${bearing.toFixed(1)}°`, + position: { + x: screenPosition.x + 10, + y: screenPosition.y - 10 + } + }; + }, + /** 增加航点预览:虚线首段起点(参考航点 → 待放置点),与 updateAddWaypointPreview 中 getAnchor 一致 */ + getAddWaypointDrawPreviewFromPosition() { + const ctx = this.addWaypointContext; + if (!ctx || !ctx.waypoints || ctx.waypoints.length === 0) return null; + const { waypoints, waypointIndex: idx, mode } = ctx; + const toCartesian = (wp) => + Cesium.Cartesian3.fromDegrees(parseFloat(wp.lng), parseFloat(wp.lat), Number(wp.alt) || 5000); + if (mode === 'after') { + return toCartesian(waypoints[idx]); + } + if (idx > 0) { + return toCartesian(waypoints[idx - 1]); + } + return null; + }, + /** 增加航点模式:参考航点 → 鼠标落点的距离与真方位(高度与放置逻辑一致,用 getClickPositionWithHeight) */ + updateAddWaypointDrawingHoverTooltip(screenPosition) { + const ctx = this.addWaypointContext; + if (!ctx) { + this.hoverTooltip.visible = false; + return; + } + const refAlt = + ctx.mode === 'before' + ? (ctx.waypoints[ctx.waypointIndex - 1] && Number(ctx.waypoints[ctx.waypointIndex - 1].alt)) || + (ctx.waypoints[ctx.waypointIndex] && Number(ctx.waypoints[ctx.waypointIndex].alt)) || + 5000 + : (ctx.waypoints[ctx.waypointIndex] && Number(ctx.waypoints[ctx.waypointIndex].alt)) || + (ctx.waypoints[ctx.waypointIndex + 1] && Number(ctx.waypoints[ctx.waypointIndex + 1].alt)) || + 5000; + const cursorPos = this.getClickPositionWithHeight(screenPosition, refAlt); + if (!cursorPos) { + this.hoverTooltip.visible = false; + return; + } + const from = this.getAddWaypointDrawPreviewFromPosition(); + if (!from) { + this.hoverTooltip.visible = false; + return; + } + const tempPositions = [from, cursorPos]; + const lengthM = this.calculateLineLength(tempPositions); + const bearing = this.calculateTrueBearing(tempPositions); + this.hoverTooltip = { + visible: true, + content: `${(lengthM / 1000).toFixed(1)}km,真方位 ${bearing.toFixed(1)}°`, + position: { + x: screenPosition.x + 10, + y: screenPosition.y - 10 + } + }; + }, /** * 从平台右键进入的航线绘制:在此之前插入(先画前段,平台为最后一站)/ 在此之后插入(平台为起点,再画后段)。 @@ -1262,6 +1433,7 @@ export default { const newPosition = this.getClickPosition(movement.endPosition); if (newPosition) { this.activeCursorPosition = newPosition; + this.updateMissionRouteDrawingHoverTooltip(movement.endPosition); // 「在此之后」仅 1 点时延后创建虚线;「在此之前」首点已在左键创建预览 const platformRoute = this.platformRouteDrawing; const onePoint = platformRoute && this.drawingPoints.length === 1 && !this.tempPreviewEntity; @@ -1283,6 +1455,8 @@ export default { }); } if (this.viewer.scene.requestRender) this.viewer.scene.requestRender(); + } else { + this.hoverTooltip.visible = false; } }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); @@ -2124,6 +2298,7 @@ export default { renderRouteWaypoints(waypoints, routeId = 'default', platformId, platform, style) { if (!waypoints || waypoints.length < 1) return; this.waypointDragPreview = null; + this.unregisterWaypointMapDomLabelsForRoute(routeId); // 清理旧线(主航线 + 透明点击层;含历史柔边层 id 以便兼容旧数据) const lineId = `route-line-${routeId}`; [lineId, lineId + '-hit', lineId + '-soft'].forEach((id) => { @@ -2230,16 +2405,14 @@ export default { outlineWidth: wpOutlineW, disableDepthTestDistance: Number.POSITIVE_INFINITY }, - label: { - text: wp.name || `盘旋${index + 1}`, - font: `${wp.labelFontSize != null ? Math.min(28, Math.max(10, Number(wp.labelFontSize))) : 14}px PingFang SC, Hiragino Sans GB, Microsoft YaHei, Helvetica Neue, Helvetica, Arial, sans-serif`, - pixelOffset: new Cesium.Cartesian2(0, -Math.max(14, pixelSize + 8)), - fillColor: Cesium.Color.fromCssColorString(wp.labelColor || '#334155'), - outlineColor: Cesium.Color.fromCssColorString('#e2e8f0'), - outlineWidth: 0.5, - style: Cesium.LabelStyle.FILL_AND_OUTLINE - } }); + this.registerWaypointMapDomLabel( + routeId, + wp.id, + wp.name || `盘旋${index + 1}`, + pixelSize, + wp + ); return; } // 有转弯半径的航点不显示中心点,只显示下方连线逻辑里添加的 entry/exit 两个虚拟点,点击虚拟点弹窗仍控制原航点(dbId) @@ -2260,16 +2433,14 @@ export default { outlineWidth: wpOutlineW, disableDepthTestDistance: Number.POSITIVE_INFINITY }, - label: { - text: wp.name || `WP${index + 1}`, - font: `${wp.labelFontSize != null ? Math.min(28, Math.max(10, Number(wp.labelFontSize))) : 14}px PingFang SC, Hiragino Sans GB, Microsoft YaHei, Helvetica Neue, Helvetica, Arial, sans-serif`, - pixelOffset: new Cesium.Cartesian2(0, -Math.max(14, pixelSize + 8)), - fillColor: Cesium.Color.fromCssColorString(wp.labelColor || '#334155'), - outlineColor: Cesium.Color.fromCssColorString('#e2e8f0'), - outlineWidth: 0.5, - style: Cesium.LabelStyle.FILL_AND_OUTLINE - } }); + this.registerWaypointMapDomLabel( + routeId, + wp.id, + wp.name || `WP${index + 1}`, + pixelSize, + wp + ); }); // 在起点渲染平台图标(当航线有关联平台且平台有图标时),默认朝向为航线方向 const iconUrl = (platform && platform.imageUrl) || (platform && platform.iconUrl); @@ -2481,53 +2652,19 @@ export default { const initialHeadingRad = pathData.path && pathData.path.length >= 2 ? this.computeHeadingFromPositions(pathData.path[0], pathData.path[1]) : undefined; const initialHeadingDeg = initialHeadingRad != null ? (initialHeadingRad * 180 / Math.PI) : 0; - const labelText = this.formatPlatformLabelText({ - name: (platform && platform.name) || '平台', - altitude: firstAlt, - speed: firstSpeed, - headingDeg: initialHeadingDeg - }); - const labelShow = this.routeLabelVisible[routeId] !== false - - // 初始化样式 if (!this.routeLabelStyles[routeId]) { this.$set(this.routeLabelStyles, routeId, { fontSize: 16, fontColor: '#000000' }); } const currentStyle = this.routeLabelStyles[routeId]; - - // 使用 Canvas 生成圆角标牌(高分屏下 scale 用于保持视觉尺寸) - const labelResult = this.createRoundedLabelCanvas({ + this.$set(this.platformScreenLabelState, routeId, { name: (platform && platform.name) || '平台', altitude: firstAlt, speed: firstSpeed, - heading: Math.round(initialHeadingDeg), + headingDeg: Math.round(initialHeadingDeg), fontSize: currentStyle.fontSize, fontColor: currentStyle.fontColor }); - - const labelEntity = this.viewer.entities.add({ - id: platformLabelId, - name: '平台标牌', - position: originalPositions[0], - show: labelShow, - properties: { routeId: routeId }, - billboard: { - image: labelResult.canvas, - scale: labelResult.scale, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - pixelOffset: new Cesium.Cartesian2(0, -42), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - scaleByDistance: new Cesium.NearFarScalar(500, 1.0, 200000, 0.65) - } - }); - // 缓存初始数据,供样式更新使用 - labelEntity.labelDataCache = { - name: (platform && platform.name) || '平台', - altitude: firstAlt, - speed: firstSpeed, - headingDeg: initialHeadingDeg - }; + this.registerPlatformMapDomLabel(routeId); } // 绘制连线(含盘旋弧) if (waypoints.length > 1) { @@ -2714,9 +2851,16 @@ export default { name: wpName, position: new Cesium.CallbackProperty(getPos, false), properties: { isMissionWaypoint: true, routeId: routeId, dbId: wp.id }, - point: { pixelSize: arcPixelSize, color: Cesium.Color.fromCssColorString(arcWpColor), outlineColor: Cesium.Color.fromCssColorString(arcWpOutline), outlineWidth: wpOutlineW, disableDepthTestDistance: Number.POSITIVE_INFINITY }, - label: { text: wpName, font: `${wp.labelFontSize != null ? Math.min(28, Math.max(10, Number(wp.labelFontSize))) : 14}px PingFang SC, Hiragino Sans GB, Microsoft YaHei, Helvetica Neue, Helvetica, Arial, sans-serif`, pixelOffset: new Cesium.Cartesian2(0, -Math.max(14, arcPixelSize + 8)), fillColor: Cesium.Color.fromCssColorString(wp.labelColor || '#334155'), outlineColor: Cesium.Color.fromCssColorString('#e2e8f0'), outlineWidth: 0.5, style: Cesium.LabelStyle.FILL_AND_OUTLINE } + point: { pixelSize: arcPixelSize, color: Cesium.Color.fromCssColorString(arcWpColor), outlineColor: Cesium.Color.fromCssColorString(arcWpOutline), outlineWidth: wpOutlineW, disableDepthTestDistance: Number.POSITIVE_INFINITY } }); + this.registerWaypointMapDomLabel( + routeId, + wp.id, + wpName, + arcPixelSize, + wp, + suffix + ); }); const arcPoints = getArcPoints(); finalPathPositions.push(...arcPoints); @@ -4365,6 +4509,9 @@ export default { }, removeRouteById(routeId) { + this.unregisterWaypointMapDomLabelsForRoute(routeId) + this.unregisterPlatformMapDomLabel(routeId) + this.$delete(this.platformScreenLabelState, routeId) // 从地图上移除所有属于该 routeId 的实体 const entityList = this.viewer.entities.values; for (let i = entityList.length - 1; i >= 0; i--) { @@ -4442,83 +4589,29 @@ export default { entity.billboard.rotation = Math.PI / 2 - heading; } } - // 更新标牌位置与文案(与数据库对应:名字、高度、速度、航向) - const labelEntity = this.viewer.entities.getById(`route-platform-label-${routeId}`); - if (labelEntity && labelEntity.position) { - labelEntity.position = cartesian; - if (labelData) { - // 更新缓存数据 - if (!labelEntity.labelDataCache) labelEntity.labelDataCache = {}; - Object.assign(labelEntity.labelDataCache, labelData); - - // 优先使用 Redis 自定义样式,如果没有则使用 routeLabelStyles(可能为空或旧值) - const customStyle = this.platformCustomStyles[routeId]; - let style = { fontSize: 16, fontColor: '#000000' }; - - if (customStyle) { - style.fontSize = customStyle.labelFontSize || 16; - style.fontColor = customStyle.labelFontColor || '#000000'; - } else if (this.routeLabelStyles[routeId]) { - style = this.routeLabelStyles[routeId]; - } - - if (labelEntity.billboard) { - // 预处理显示数据(取整),既解决了小数点过长问题,也用于差异检测 - const displayData = { - name: labelData.name || '平台', - altitude: Math.round(Number(labelData.altitude || 0)), - speed: Math.round(Number(labelData.speed || 0)), - heading: Math.round(Number(labelData.headingDeg || 0)) - }; - - // 差异检测:如果关键数据和样式都没变,就不重绘 - const last = labelEntity._lastRenderParams; - const now = Date.now(); - // 节流控制:只有当数据变化且距离上次更新超过 1000ms 时才更新(首次除外) - // 这样可以保证位置平滑移动(position每帧更新),但纹理贴图每秒只更新一次,彻底解决闪烁 - if (last && - last.name === displayData.name && - last.altitude === displayData.altitude && - last.speed === displayData.speed && - last.heading === displayData.heading && - last.fontSize === style.fontSize && - last.fontColor === style.fontColor) { - // 数据完全一致,无需更新 - return; - } - - // 如果数据变了,但还没到 1秒 间隔,且不是强制更新(比如刚修改了样式),则跳过 - if (last && (now - (labelEntity._lastUpdateTime || 0) < 1000)) { - return; - } - - const labelResult = this.createRoundedLabelCanvas({ - name: displayData.name, - altitude: displayData.altitude, - speed: displayData.speed, - heading: displayData.heading, - fontSize: style.fontSize, - fontColor: style.fontColor - }); - labelEntity.billboard.image = labelResult.canvas; - labelEntity.billboard.scale = labelResult.scale; - - // 记录本次渲染参数和时间 - labelEntity._lastRenderParams = { - ...displayData, - fontSize: style.fontSize, - fontColor: style.fontColor - }; - labelEntity._lastUpdateTime = now; - - // 确保在 requestRenderMode 下能刷出来 - if (this.viewer.scene.requestRenderMode) { - this.viewer.scene.requestRender(); - } - } else if (labelEntity.label) { - labelEntity.label.text = this.formatPlatformLabelText(labelData); - } - } + // 屏幕 DOM 标牌:数据挂在 platformScreenLabelState,位置每帧跟平台图标 world→screen + if (labelData) { + const customStyle = this.platformCustomStyles[routeId]; + let style = { fontSize: 16, fontColor: '#000000' }; + if (customStyle) { + style.fontSize = customStyle.labelFontSize || 16; + style.fontColor = customStyle.labelFontColor || '#000000'; + } else if (this.routeLabelStyles[routeId]) { + style = this.routeLabelStyles[routeId]; + } + const prev = this.platformScreenLabelState[routeId] || {}; + this.$set(this.platformScreenLabelState, routeId, { + ...prev, + ...labelData, + name: labelData.name != null ? labelData.name : (prev.name || '平台'), + altitude: + labelData.altitude != null ? Number(labelData.altitude) : prev.altitude, + speed: labelData.speed != null ? Number(labelData.speed) : prev.speed, + headingDeg: + labelData.headingDeg != null ? Number(labelData.headingDeg) : prev.headingDeg, + fontSize: style.fontSize, + fontColor: style.fontColor + }); } }, openLaunchMissileDialog() { @@ -4875,8 +4968,8 @@ export default { // 确保 Cesium 已加载 Cesium.buildModuleUrl.setBaseUrl(window.CESIUM_BASE_URL) Cesium.Ion.defaultAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJjN2MzMmE5OS01NGU3LTQzOGQtYjdjZi1mNGIwZTFjZjQ0NmEiLCJpZCI6MTQ0MDc2LCJpYXQiOjE2ODU3NjY1OTN9.iCmFY-5WNdvyAT-EO2j-unrFm4ZN9J6aSuB2wElQZ-I' - // 使用设备像素比提升高分屏下字体与图形清晰度(上限 2 兼顾性能) - const resolutionScale = Math.min(2, window.devicePixelRatio || 1); + // 使用设备像素比提升高分屏下瓦片与图形清晰度(上限 3 兼顾性能与清晰度) + const resolutionScale = Math.min(3, window.devicePixelRatio || 1); this.viewer = new Cesium.Viewer('cesiumViewer', { animation: false, fullscreenButton: false, @@ -4913,6 +5006,11 @@ export default { this.viewer.scene.requestRender() }) } + this.ensureMapScreenDomLabelRegistry() + this._mapScreenDomLabelPostRender = () => { + this.syncMapScreenDomLabels() + } + this.viewer.scene.postRender.addEventListener(this._mapScreenDomLabelPostRender) this.loadOfflineMap() this.setup2DConstraints() // 禁用右键拖拽缩放,仅保留滚轮缩放 @@ -4945,7 +5043,8 @@ export default { this.$emit('viewer-ready') // 1. 定义全局拾取处理器(含防抖,避免双击误触导致相机高度剧烈变化) - this.viewer.scene.postProcessStages.fxaa.enabled = true + // 关闭 FXAA 以提升瓦片清晰度(FXAA 会对整幅画面做模糊处理) + this.viewer.scene.postProcessStages.fxaa.enabled = false this.handler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas); this.handler.setInputAction((click) => { // 隐藏右键菜单 @@ -4958,6 +5057,50 @@ export default { this.lastClickWasDrag = false; return; } + // 航段范围选取:依次拾取两个航点 + if (this.routeSegmentPickContext) { + const ctx = this.routeSegmentPickContext; + const pickedObject = this.viewer.scene.pick(click.position); + if (!Cesium.defined(pickedObject) || !pickedObject.id) { + this.$message && this.$message.info('请点击本航线上的航点'); + return; + } + const entity = pickedObject.id; + const now = Cesium.JulianDate.now(); + const props = entity.properties ? entity.properties.getValue(now) : null; + if (!props || !props.isMissionWaypoint) { + this.$message && this.$message.info('请点击航点'); + return; + } + let rId = props.routeId; + if (rId && rId.getValue) rId = rId.getValue(); + if (String(rId) !== String(ctx.routeId)) { + this.$message && this.$message.info('请点击当前航线的航点'); + return; + } + let dbId = props.dbId; + if (dbId && dbId.getValue) dbId = dbId.getValue(); + const ids = ctx.waypoints.map((w) => w.id); + const idx = ids.indexOf(dbId); + if (idx < 0) { + this.$message && this.$message.warning('未在航线序列中找到该航点'); + return; + } + if (ctx.firstIndex == null) { + this.$set(ctx, 'firstIndex', idx); + this.$message && this.$message.info('已选起点,请再点击一航点作为终点'); + return; + } + const lo = Math.min(ctx.firstIndex, idx); + const hi = Math.max(ctx.firstIndex, idx); + const mode = ctx.mode; + const routeId = ctx.routeId; + const full = ctx.waypoints; + const routeStyle = ctx.routeStyle; + this.routeSegmentPickContext = null; + this.beginRouteSegmentMovePreview(routeId, full, lo, hi, mode, routeStyle); + return; + } // 航线复制预览模式:左键放置 if (this.copyPreviewWaypoints && this.copyPreviewWaypoints.length > 0) { const placePosition = this.getClickPosition(click.position); @@ -4984,8 +5127,18 @@ export default { ...(wp.pointType && { pointType: wp.pointType }), ...(wp.holdParams != null && { holdParams: typeof wp.holdParams === 'string' ? wp.holdParams : JSON.stringify(wp.holdParams) }) })); + const segMeta = this.routeSegmentPlaceMeta; this.clearCopyPreview(); - this.$emit('route-copy-placed', points); + if (segMeta) { + this.$emit('route-segment-placed', { + mode: segMeta.mode, + sourceRouteId: segMeta.sourceRouteId, + removedWaypointIds: segMeta.removedWaypointIds, + points + }); + } else { + this.$emit('route-copy-placed', points); + } } return; } @@ -5120,7 +5273,7 @@ export default { // 航点拖拽:左键按下仅记录 pending,移动超过阈值后才真正开始拖拽(避免小范围移动带跑地图) this.handler.setInputAction((click) => { - if (this.isDrawing || this.copyPreviewWaypoints) return; + if (this.isDrawing || this.copyPreviewWaypoints || this.routeSegmentPickContext) return; const pickedObject = this.viewer.scene.pick(click.position); if (!Cesium.defined(pickedObject) || !pickedObject.id) return; const now = Cesium.JulianDate.now(); @@ -5197,8 +5350,12 @@ export default { const cartesian = this.getClickPosition(movement.endPosition); if (cartesian) { this.copyPreviewMouseCartesian = cartesian; - this.updateCopyPreviewPolyline(); - if (this.viewer.scene.requestRender) this.viewer.scene.requestRender(); + if (this.routeSegmentPlaceMeta) { + if (this.viewer.scene.requestRender) this.viewer.scene.requestRender(); + } else { + this.updateCopyPreviewPolyline(); + if (this.viewer.scene.requestRender) this.viewer.scene.requestRender(); + } } } if (this.addWaypointContext) { @@ -5209,7 +5366,10 @@ export default { const cartesian = this.getClickPositionWithHeight(movement.endPosition, refAlt); if (cartesian) { ctx.mouseCartesian = cartesian; + this.updateAddWaypointDrawingHoverTooltip(movement.endPosition); if (this.viewer.scene.requestRender) this.viewer.scene.requestRender(); + } else { + this.hoverTooltip.visible = false; } } if (this.airspacePositionEditContext) { @@ -5283,7 +5443,53 @@ export default { if (this.isDrawing) { return; } + // 框选平台模式:右键点在「已选平台」上弹出批量删除;否则退出模式 + if (this.platformBoxSelectMode) { + const multi = this.platformMultiSelected || [] + if (multi.length > 0) { + const drillPicks = this.viewer.scene.drillPick(click.position, 20, 11, 11) + let hitInSelection = null + for (let i = 0; i < drillPicks.length; i++) { + const p = drillPicks[i] + const obj = p && p.id + if (!obj) continue + const ed = this.allEntities.find( + e => e.type === 'platformIcon' && !e.isWhiteboard && e.entity === obj + ) + if (ed && multi.indexOf(ed) !== -1) { + hitInSelection = ed + break + } + } + if (hitInSelection) { + const now = Cesium.JulianDate.now() + let anchorCartesian = null + if (hitInSelection.entity && hitInSelection.entity.position) { + try { + anchorCartesian = hitInSelection.entity.position.getValue(now) + } catch (e) {} + } + this.contextMenu = { + visible: true, + position: { x: click.position.x, y: click.position.y }, + entityData: { type: 'platformBoxSelection', count: multi.length }, + anchorCartesian, + pickList: null, + pickIndex: 0 + } + return + } + } + this.requestExitPlatformBoxSelectFromMap() + return + } // 若处于航线复制预览,右键取消 + if (this.routeSegmentPickContext) { + this.routeSegmentPickContext = null; + this.contextMenu.visible = false; + this.$message && this.$message.info('已取消航段选取'); + return; + } if (this.copyPreviewWaypoints) { this.clearCopyPreview(); this.contextMenu.visible = false; @@ -5512,10 +5718,10 @@ export default { const pickedEntity = pick.id || pick.object; if (!pickedEntity || seenEntities.has(pickedEntity)) continue; const entityData = this.resolveEntityDataFromPick(pickedEntity); - if (entityData) { - seenEntities.add(pickedEntity); - pickList.push({ entityData, pickedEntity }); - } + if (entityData) { + seenEntities.add(pickedEntity); + pickList.push({entityData, pickedEntity}); + } } } if (pickList.length > 0) { @@ -5557,25 +5763,16 @@ export default { return } const routeId = ed.routeId - const labelEntity = this.viewer && this.viewer.entities && this.viewer.entities.getById(`route-platform-label-${routeId}`) let fontSize = 16 let color = '#333333' - if (labelEntity && labelEntity.label) { - const now = Cesium.JulianDate.now() - const fontValue = labelEntity.label.font && labelEntity.label.font.getValue - ? labelEntity.label.font.getValue(now) - : labelEntity.label.font - if (fontValue && typeof fontValue === 'string') { - const match = fontValue.match(/(\d+)px/) - if (match) fontSize = parseInt(match[1], 10) - } - const fill = labelEntity.label.fillColor - if (fill) { - const c = fill.getValue ? fill.getValue(now) : fill - if (c && c.toCssColorString) { - color = c.toCssColorString() - } - } + if (this.routeLabelStyles[routeId]) { + fontSize = this.routeLabelStyles[routeId].fontSize + color = this.routeLabelStyles[routeId].fontColor + } + const pls = this.platformScreenLabelState[routeId] + if (pls) { + if (pls.fontSize != null) fontSize = Number(pls.fontSize) + if (pls.fontColor) color = pls.fontColor } this.editPlatformLabelForm = { routeId, @@ -5592,15 +5789,15 @@ export default { this.editPlatformLabelDialogVisible = false return } - const labelEntity = this.viewer.entities.getById(`route-platform-label-${routeId}`) - if (labelEntity && labelEntity.label) { - const size = Math.max(10, Math.min(32, Number(this.editPlatformLabelForm.fontSize) || 16)) - const color = this.editPlatformLabelForm.color || '#333333' - labelEntity.label.font = `${size}px Microsoft YaHei` - labelEntity.label.fillColor = Cesium.Color.fromCssColorString(color) - if (this.viewer.scene && this.viewer.scene.requestRenderMode) { - this.viewer.scene.requestRender() - } + const size = Math.max(10, Math.min(32, Number(this.editPlatformLabelForm.fontSize) || 16)) + const color = this.editPlatformLabelForm.color || '#333333' + this.$set(this.routeLabelStyles, routeId, { fontSize: size, fontColor: color }) + const st = this.platformScreenLabelState[routeId] + if (st) { + this.$set(this.platformScreenLabelState, routeId, { ...st, fontSize: size, fontColor: color }) + } + if (this.viewer.scene && this.viewer.scene.requestRenderMode) { + this.viewer.scene.requestRender() } this.editPlatformLabelDialogVisible = false }, @@ -5658,6 +5855,126 @@ export default { this.editPlatformStyleDialogVisible = false }, + _restoreBoxSelectCamera() { + if (!this._cameraDisabledForBoxSelect || !this.viewer || !this.viewer.scene) return + this.viewer.scene.screenSpaceCameraController.enableInputs = this.platformIconDragCameraEnabled + this._cameraDisabledForBoxSelect = false + }, + + clearPlatformMultiSelectHighlight() { + const list = this.platformMultiSelected || [] + for (const ed of list) { + if (ed.entity && ed.entity.billboard) { + const orig = ed._boxSelectOrigBillboardColor + ed.entity.billboard.color = orig !== undefined && orig !== null ? orig : Cesium.Color.WHITE + ed._boxSelectOrigBillboardColor = undefined + } + } + }, + + applyPlatformMultiSelectHighlight(eds) { + if (!eds || !eds.length) return + for (const ed of eds) { + if (!ed.entity || !ed.entity.billboard) continue + if (ed._boxSelectOrigBillboardColor === undefined) { + ed._boxSelectOrigBillboardColor = ed.entity.billboard.color + } + ed.entity.billboard.color = Cesium.Color.fromCssColorString('#0088ff').withAlpha(0.9) + } + }, + + _clearPlatformMultiSelectOnly() { + this.clearPlatformMultiSelectHighlight() + this.platformMultiSelected = [] + }, + + _startPlatformBoxSelect(screenPos) { + this.platformBoxSelectAnchor = { x: screenPos.x, y: screenPos.y } + this.platformBoxSelectCurrent = { x: screenPos.x, y: screenPos.y } + this.platformBoxSelectDragging = false + this.platformBoxSelectInnerRectStyle = { display: 'none' } + }, + + _startPlatformGroupDrag(screenPos) { + const ellipsoid = this.viewer.scene.globe.ellipsoid + const cart = this.viewer.camera.pickEllipsoid(screenPos, ellipsoid) + if (!cart) return + this.groupDragPickStart = Cesium.Cartesian3.clone(cart) + const now = Cesium.JulianDate.now() + this.groupDragEntityCartesians = new Map() + for (const ed of this.platformMultiSelected) { + if (!ed.entity || !ed.entity.position) continue + const p = ed.entity.position.getValue(now) + if (p) this.groupDragEntityCartesians.set(ed.id, Cesium.Cartesian3.clone(p)) + } + this.groupDragScreenStart = { x: screenPos.x, y: screenPos.y } + this.pendingGroupDrag = true + this.draggingPlatformGroup = false + }, + + _finalizePlatformBoxSelect() { + if (!this.platformBoxSelectAnchor || !this.platformBoxSelectCurrent) return + const ax = this.platformBoxSelectAnchor.x + const ay = this.platformBoxSelectAnchor.y + const cx = this.platformBoxSelectCurrent.x + const cy = this.platformBoxSelectCurrent.y + const minX = Math.min(ax, cx) + const maxX = Math.max(ax, cx) + const minY = Math.min(ay, cy) + const maxY = Math.max(ay, cy) + const w = maxX - minX + const h = maxY - minY + if (w < 5 && h < 5) { + this.clearPlatformMultiSelectHighlight() + this.platformMultiSelected = [] + return + } + const now = Cesium.JulianDate.now() + const scene = this.viewer.scene + const next = [] + for (const ed of this.allEntities) { + if (ed.type !== 'platformIcon' || ed.isWhiteboard) continue + if (!ed.entity || !ed.entity.position) continue + const pos = ed.entity.position.getValue(now) + if (!pos) continue + const scr = Cesium.SceneTransforms.worldToWindowCoordinates(scene, pos) + if (!scr) continue + if (scr.x >= minX && scr.x <= maxX && scr.y >= minY && scr.y <= maxY) { + next.push(ed) + } + } + this.clearPlatformMultiSelectHighlight() + this.platformMultiSelected = next + this.applyPlatformMultiSelectHighlight(next) + if (next.length === 0) { + this.$message && this.$message.info('框选范围内没有房间平台图标(仅统计地图上的独立平台图标)') + } + }, + + /** 右键或外部请求:清理地图状态并通知父组件关闭模式 */ + requestExitPlatformBoxSelectFromMap() { + this.exitPlatformBoxSelectMode() + this.contextMenu.visible = false + this.$emit('request-exit-platform-box-select') + this.$message && this.$message.info('已退出框选平台模式') + }, + + /** 退出框选模式时由父组件 ref 或 watch 调用:清理状态与相机锁定 */ + exitPlatformBoxSelectMode() { + this.platformBoxSelectAnchor = null + this.platformBoxSelectCurrent = null + this.platformBoxSelectDragging = false + this.platformBoxSelectInnerRectStyle = { display: 'none' } + this.pendingGroupDrag = false + this.draggingPlatformGroup = false + this.groupDragPickStart = null + this.groupDragEntityCartesians = null + this.groupDragScreenStart = null + this._restoreBoxSelectCamera() + this.clearPlatformMultiSelectHighlight() + this.platformMultiSelected = [] + }, + /** 平台图标图形化操作:伸缩框(旋转手柄 + 四角缩放)、拖拽移动、单击选中 */ initPlatformIconInteraction() { const canvas = this.viewer.scene.canvas; @@ -5667,8 +5984,10 @@ export default { if (this.isDrawing || this.rotatingPlatformIcon) return; const picked = this.viewer.scene.pick(click.position); this.clickedOnEmpty = !Cesium.defined(picked) || !picked.id; + let idStr = ''; + let entityData = null; if (picked && picked.id) { - const idStr = typeof picked.id === 'string' ? picked.id : (picked.id.id || ''); + idStr = typeof picked.id === 'string' ? picked.id : (picked.id.id || ''); if (idStr.endsWith('-scale-frame') || idStr.endsWith('-scale-frame-glow')) { this.clickedOnEmpty = false; return; @@ -5689,19 +6008,91 @@ export default { return; } } - let entityData = this.allEntities.find(e => e.type === 'platformIcon' && e.entity === picked.id); + entityData = this.allEntities.find(e => e.type === 'platformIcon' && e.entity === picked.id); if (!entityData && idStr.startsWith('wb_')) { entityData = this.whiteboardEntityDataMap && this.whiteboardEntityDataMap[idStr]; } - if (entityData && entityData.type === 'platformIcon') { - this.pendingDragIcon = entityData; - this.dragStartScreenPos = { x: click.position.x, y: click.position.y }; + } + if (this.platformBoxSelectMode && !this.rotatingPlatformIcon) { + const isRoomPlatform = entityData && entityData.type === 'platformIcon' && !entityData.isWhiteboard; + if (isRoomPlatform && this.platformMultiSelected.length > 0 && this.platformMultiSelected.indexOf(entityData) !== -1) { + this._startPlatformGroupDrag(click.position); + this.clickedOnEmpty = false; + return; + } + if (!isRoomPlatform) { + this._startPlatformBoxSelect(click.position); return; } + this._clearPlatformMultiSelectOnly(); + } + if (entityData && entityData.type === 'platformIcon') { + this.pendingDragIcon = entityData; + this.dragStartScreenPos = { x: click.position.x, y: click.position.y }; + return; } }, Cesium.ScreenSpaceEventType.LEFT_DOWN); this.platformIconHandler.setInputAction((movement) => { + if (this.platformBoxSelectAnchor && this.platformBoxSelectMode) { + this.platformBoxSelectCurrent = { x: movement.endPosition.x, y: movement.endPosition.y }; + const ax = this.platformBoxSelectAnchor.x; + const ay = this.platformBoxSelectAnchor.y; + const cx = this.platformBoxSelectCurrent.x; + const cy = this.platformBoxSelectCurrent.y; + const dist = Math.sqrt((cx - ax) * (cx - ax) + (cy - ay) * (cy - ay)); + if (!this.platformBoxSelectDragging && dist > 5) { + this.platformBoxSelectDragging = true; + this.platformIconDragCameraEnabled = this.viewer.scene.screenSpaceCameraController.enableInputs; + this.viewer.scene.screenSpaceCameraController.enableInputs = false; + this._cameraDisabledForBoxSelect = true; + } + if (this.platformBoxSelectDragging) { + const minX = Math.min(ax, cx); + const minY = Math.min(ay, cy); + const w = Math.abs(cx - ax); + const h = Math.abs(cy - ay); + this.platformBoxSelectInnerRectStyle = { + display: 'block', + left: minX + 'px', + top: minY + 'px', + width: w + 'px', + height: h + 'px' + }; + this.viewer.scene.requestRender(); + } + return; + } + if (this.pendingGroupDrag && this.platformBoxSelectMode) { + const sx = this.groupDragScreenStart.x; + const sy = this.groupDragScreenStart.y; + const ex = movement.endPosition.x; + const ey = movement.endPosition.y; + if (!this.draggingPlatformGroup && Math.sqrt((ex - sx) * (ex - sx) + (ey - sy) * (ey - sy)) > (this.PLATFORM_DRAG_THRESHOLD_PX || 10)) { + this.draggingPlatformGroup = true; + this.platformIconDragCameraEnabled = this.viewer.scene.screenSpaceCameraController.enableInputs; + this.viewer.scene.screenSpaceCameraController.enableInputs = false; + this._cameraDisabledForBoxSelect = true; + } + } + if (this.draggingPlatformGroup && this.groupDragPickStart && this.groupDragEntityCartesians) { + const cur = this.viewer.camera.pickEllipsoid(movement.endPosition, this.viewer.scene.globe.ellipsoid); + if (cur) { + if (!this._cartScratchDelta) this._cartScratchDelta = new Cesium.Cartesian3(); + Cesium.Cartesian3.subtract(cur, this.groupDragPickStart, this._cartScratchDelta); + for (const ed of this.platformMultiSelected) { + const start = this.groupDragEntityCartesians.get(ed.id); + if (!start || !ed.entity) continue; + const newPos = Cesium.Cartesian3.add(start, this._cartScratchDelta, new Cesium.Cartesian3()); + ed.entity.position = newPos; + const ll = this.cartesianToLatLng(newPos); + ed.lat = ll.lat; + ed.lng = ll.lng; + } + } + this.viewer.scene.requestRender(); + return; + } if (this.draggingRotateHandle) { const ed = this.draggingRotateHandle; if (ed.entity && ed.entity.position) { @@ -5790,6 +6181,32 @@ export default { }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); this.platformIconHandler.setInputAction(() => { + if (this.platformBoxSelectAnchor) { + if (this.platformBoxSelectDragging) { + this._finalizePlatformBoxSelect(); + } else { + this.clearPlatformMultiSelectHighlight(); + this.platformMultiSelected = []; + } + this.platformBoxSelectAnchor = null; + this.platformBoxSelectCurrent = null; + this.platformBoxSelectDragging = false; + this.platformBoxSelectInnerRectStyle = { display: 'none' }; + this._restoreBoxSelectCamera(); + return; + } + if (this.pendingGroupDrag || this.draggingPlatformGroup) { + if (this.draggingPlatformGroup && this.platformMultiSelected.length) { + this.$emit('platform-icons-batch-updated', [...this.platformMultiSelected]); + } + this.pendingGroupDrag = false; + this.draggingPlatformGroup = false; + this.groupDragPickStart = null; + this.groupDragEntityCartesians = null; + this.groupDragScreenStart = null; + this._restoreBoxSelectCamera(); + return; + } if (this.pendingDragIcon) { if (this.selectedPlatformIcon === this.pendingDragIcon) { this.removeTransformHandles(this.selectedPlatformIcon); @@ -5869,6 +6286,10 @@ export default { this.hoverTooltip.visible = false; return; } + // 增加航点模式由主 handler 的 MOUSE_MOVE 更新距离/方位提示,此处勿覆盖 + if (this.addWaypointContext) { + return; + } const pickedObject = this.viewer.scene.pick(movement.endPosition) if (Cesium.defined(pickedObject) && pickedObject.id) { const pickedEntity = pickedObject.id @@ -6141,6 +6562,7 @@ export default { } // 清理点实体数组 if (this.drawingPointEntities && this.drawingPointEntities.length > 0) { + this.unregisterDrawingPointDomLabelsForEntities(this.drawingPointEntities) this.drawingPointEntities.forEach(pointEntity => { this.viewer.entities.remove(pointEntity); }); @@ -6172,6 +6594,7 @@ export default { this.tempPreviewEntity = null; } if (this.drawingPointEntities) { + this.unregisterDrawingPointDomLabelsForEntities(this.drawingPointEntities) this.drawingPointEntities.forEach(pointEntity => { this.viewer.entities.remove(pointEntity); }); @@ -6222,10 +6645,9 @@ export default { const bearing = this.calculateTrueBearing(tempPositions); // 根据工具模式决定显示格式 if (this.toolMode === 'ranging') { - // 测距模式:使用千米,简化格式 this.hoverTooltip = { visible: true, - content: `${(length / 1000).toFixed(1)}km ,${bearing.toFixed(1)}°`, + content: `${this.formatRangingLengthText(length)} ,${bearing.toFixed(1)}°`, position: { x: movement.endPosition.x + 10, y: movement.endPosition.y - 10 @@ -6270,20 +6692,20 @@ export default { pixelSize: this.defaultStyles.point.size, color: Cesium.Color.fromCssColorString(this.defaultStyles.point.color), outlineColor: Cesium.Color.WHITE, - outlineWidth: 2 - }, - label: { - text: isStartPoint ? '起点' : `${(cumulativeDistance / 1000).toFixed(2)}km`, - font: '14px Microsoft YaHei, sans-serif', - fillColor: Cesium.Color.BLACK, - backgroundColor: Cesium.Color.WHITE.withAlpha(0.8), - showBackground: true, - horizontalOrigin: Cesium.HorizontalOrigin.LEFT, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - pixelOffset: new Cesium.Cartesian2(15, 0), + outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY } }); + // 测距模式仅保留 Cesium 圆点;空域画线仍用 DOM 显示起点/累计距离 + if (this.toolMode !== 'ranging') { + this.ensureMapScreenDomLabelRegistry() + this._mapScreenDomLabelRegistry[`map-dom-drawlpt-${pointId}`] = { + kind: 'drawPoint', + entityId: pointId, + pixelOffset: { x: 15, y: 0 }, + text: isStartPoint ? '起点' : `${(cumulativeDistance / 1000).toFixed(2)}km` + } + } this.drawingPointEntities.push(pointEntity); // 移除旧的预览虚线 if (this.tempPreviewEntity) { @@ -7503,6 +7925,16 @@ export default { this.drawingMode = null; }, Cesium.ScreenSpaceEventType.RIGHT_CLICK); }, + /** 插入文本仅用 DOM 显示时无 Cesium 图元,scene.pick 无法命中;用极淡点作为右键拾取目标 */ + getInsertedTextPickPointGraphics() { + return { + pixelSize: 12, + color: Cesium.Color.WHITE.withAlpha(0.1), + outlineColor: Cesium.Color.TRANSPARENT, + outlineWidth: 0, + disableDepthTestDistance: Number.POSITIVE_INFINITY + } + }, // 添加文本实体 addTextEntity(position, text) { const { lat, lng } = this.cartesianToLatLng(position) @@ -7523,22 +7955,7 @@ export default { id: id, name: `文本 ${this.entityCounter}`, position: position, - label: { - text: text, - font: this.defaultStyles.text.font, - fillColor: Cesium.Color.fromCssColorString(this.defaultStyles.text.color), - outlineColor: Cesium.Color.fromCssColorString('#e5e5e5'), - outlineWidth: 1, - style: Cesium.LabelStyle.FILL_AND_OUTLINE, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - backgroundColor: Cesium.Color.fromCssColorString(this.defaultStyles.text.backgroundColor), - backgroundPadding: new Cesium.Cartesian2(8, 5), - pixelOffset: new Cesium.Cartesian2(0, 0), - // 模仿高德:常用缩放下几乎不变,拉远时略缩小、拉近时略放大,过渡柔和 - scaleByDistance: new Cesium.NearFarScalar(200, 1.12, 1200000, 0.72), - translucencyByDistance: new Cesium.NearFarScalar(300, 1.0, 600000, 0.88) - } + point: this.getInsertedTextPickPointGraphics() }) const entityData = { id, @@ -7554,6 +7971,7 @@ export default { label: `文本 ${this.entityCounter}` } this.allEntities.push(entityData) + this.registerMapTextForInsertedText(entityData) entity.clickHandler = (e) => { this.selectEntity(entityData) e.stopPropagation() @@ -7703,19 +8121,21 @@ export default { pixelSize: this.defaultStyles.point.size, color: Cesium.Color.fromCssColorString(this.defaultStyles.point.color), outlineColor: Cesium.Color.WHITE, - outlineWidth: 2 - }, - label: { - text: `${this.entityCounter}`, - font: '14px Arial', - fillColor: Cesium.Color.WHITE, - outlineColor: Cesium.Color.BLACK, outlineWidth: 2, - style: Cesium.LabelStyle.FILL_AND_OUTLINE, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER + disableDepthTestDistance: Number.POSITIVE_INFINITY } }) + // 测距模式「点」工具:不要 DOM 序号框,只显示圆点 + if (this.toolMode !== 'ranging') { + this.ensureMapScreenDomLabelRegistry() + this._mapScreenDomLabelRegistry[`map-dom-mappoint-${id}`] = { + kind: 'drawPoint', + entityId: id, + pixelOffset: { x: 0, y: 0 }, + text: `${this.entityCounter}`, + centered: true + } + } const entityData = { id, type: 'point', @@ -7735,6 +8155,7 @@ export default { return entityData }, addLineEntity(positions, pointEntities = []) { + this.unregisterDrawingPointDomLabelsForEntities(pointEntities) this.entityCounter++ const id = `line_${this.entityCounter}` const entityData = { @@ -7930,67 +8351,77 @@ export default { } return totalLength }, - /** 更新测距线段每段终点的标签:显示累计长度和当前段角度(替代悬停显示) */ + /** 测距:米 → 当前单位文案(方位角另拼);isTotal 时最后一段显示「共…」 */ + formatRangingLengthText(meters, opts = {}) { + const m = Number(meters) + if (!Number.isFinite(m)) return this.rangingDistanceUnit === 'nm' ? '0.00海里' : '0.0km' + const isTotal = opts.isTotal === true + if (this.rangingDistanceUnit === 'nm') { + const v = (m / 1852).toFixed(2) + return isTotal ? `共${v}海里` : `${v}海里` + } + const v = (m / 1000).toFixed(1) + return isTotal ? `共${v}km` : `${v}km` + }, + /** 更新测距/空域线段每段终点的标签:屏幕 DOM,与 HoverTooltip 同类渲染 */ updateLineSegmentLabels(entityData) { + if (!entityData || !entityData.id) return + if (entityData.segmentLabelEntities && entityData.segmentLabelEntities.length) { + entityData.segmentLabelEntities.forEach((le) => { + try { + this.viewer.entities.remove(le) + } catch (e) {} + }) + entityData.segmentLabelEntities = [] + } let positions = entityData.positions if (!positions && entityData.points && entityData.points.length >= 2) { - positions = entityData.points.map(p => Cesium.Cartesian3.fromDegrees(p.lng, p.lat)) + positions = entityData.points.map((p) => Cesium.Cartesian3.fromDegrees(p.lng, p.lat)) + } + if (!positions || positions.length < 2) { + this.unregisterLineSegmentDomLabels(entityData.id) + return } - if (!positions || positions.length < 2) return + this.ensureMapScreenDomLabelRegistry() + const lineId = entityData.id + const nSeg = positions.length - 1 + const pref = `map-dom-seg-${lineId}-` + Object.keys(this._mapScreenDomLabelRegistry).forEach((k) => { + if (!k.startsWith(pref)) return + const si = parseInt(k.slice(pref.length), 10) + if (!Number.isFinite(si) || si >= nSeg) { + delete this._mapScreenDomLabelRegistry[k] + } + }) const bearingType = entityData.bearingType || 'true' - const bearingFn = bearingType === 'magnetic' ? this.calculateMagneticBearing.bind(this) : this.calculateTrueBearing.bind(this) + const bearingFn = + bearingType === 'magnetic' + ? this.calculateMagneticBearing.bind(this) + : this.calculateTrueBearing.bind(this) let cumulativeLength = 0 - // 与悬停提示一致:深色半透明背景、白色文字 - const labelStyle = { - font: '14px Microsoft YaHei, sans-serif', - fillColor: Cesium.Color.WHITE, - backgroundColor: Cesium.Color.BLACK.withAlpha(0.8), - showBackground: true, - backgroundPadding: new Cesium.Cartesian2(12, 8), - horizontalOrigin: Cesium.HorizontalOrigin.LEFT, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - pixelOffset: new Cesium.Cartesian2(15, 0), - disableDepthTestDistance: Number.POSITIVE_INFINITY - } for (let i = 0; i < positions.length - 1; i++) { const segLen = Cesium.Cartesian3.distance(positions[i], positions[i + 1]) cumulativeLength += segLen const bearing = bearingFn([positions[i], positions[i + 1]]) - const text = this.toolMode === 'ranging' - ? `${(cumulativeLength / 1000).toFixed(1)}km ,${bearing.toFixed(1)}°` - : `累计长度:${cumulativeLength.toFixed(2)} 米\n${bearingType === 'magnetic' ? '磁方位' : '真方位'}:${bearing.toFixed(2)}°` + const text = + this.toolMode === 'ranging' + ? `${this.formatRangingLengthText(cumulativeLength)} ,${bearing.toFixed(1)}°` + : `累计长度:${cumulativeLength.toFixed(2)} 米\n${ + bearingType === 'magnetic' ? '磁方位' : '真方位' + }:${bearing.toFixed(2)}°` const isLast = i === positions.length - 2 - const displayText = isLast && this.toolMode === 'ranging' ? `共${(cumulativeLength / 1000).toFixed(1)}km ,${bearing.toFixed(1)}°` : text - if (entityData.pointEntities && entityData.pointEntities[i + 1]) { - const pe = entityData.pointEntities[i + 1] - if (pe.label) { - pe.label.text = displayText - pe.label.fillColor = labelStyle.fillColor - pe.label.backgroundColor = labelStyle.backgroundColor - pe.label.showBackground = labelStyle.showBackground - pe.label.backgroundPadding = labelStyle.backgroundPadding - } - } else { - if (!entityData.segmentLabelEntities) entityData.segmentLabelEntities = [] - if (entityData.segmentLabelEntities[i]) { - if (entityData.segmentLabelEntities[i].label) entityData.segmentLabelEntities[i].label.text = displayText - entityData.segmentLabelEntities[i].position = positions[i + 1] - } else { - const labelEntity = this.viewer.entities.add({ - position: positions[i + 1], - label: { ...labelStyle, text: displayText } - }) - entityData.segmentLabelEntities[i] = labelEntity - } - } - } - if (entityData.segmentLabelEntities) { - const toRemove = entityData.segmentLabelEntities.length - (positions.length - 1) - if (toRemove > 0) { - for (let j = entityData.segmentLabelEntities.length - 1; j >= positions.length - 1; j--) { - this.viewer.entities.remove(entityData.segmentLabelEntities[j]) - entityData.segmentLabelEntities.pop() - } + const displayText = + isLast && this.toolMode === 'ranging' + ? `${this.formatRangingLengthText(cumulativeLength, { isTotal: true })} ,${bearing.toFixed(1)}°` + : text + const id = `${pref}${i}` + this._mapScreenDomLabelRegistry[id] = { + kind: 'segment', + lineId, + segmentVertexIndex: i + 1, + pixelOffset: { x: 15, y: 0 }, + text: displayText, + multiline: displayText.indexOf('\n') >= 0 } } }, @@ -8206,8 +8637,9 @@ export default { entity.polyline.material = Cesium.Color.fromCssColorString(data.borderColor || data.color) entity.polyline.width = data.width } - if (data.name && data.centerEntity && data.centerEntity.label) { - data.centerEntity.label.text = data.name + // 地图绘制的威力区(独立圆心点实体):名称走 DOM + if (data.centerEntity) { + this.registerMapTextForPowerZone(data) } break case 'sector': @@ -8231,12 +8663,19 @@ export default { entity.polyline.width = data.width } break - case 'text': - if (entity.label) { - entity.label.fillColor = Cesium.Color.fromCssColorString(data.color) - entity.label.font = data.font + case 'text': { + this.ensureMapScreenDomLabelRegistry() + const tkey = entity.id ? `map-dom-maptext-text-${entity.id}` : null + if (tkey && this._mapScreenDomLabelRegistry && this._mapScreenDomLabelRegistry[tkey]) { + const reg = this._mapScreenDomLabelRegistry[tkey] + reg.color = data.color || '#1a1a1a' + if (data.font) { + const m = String(data.font).match(/(\d+)px/) + if (m) reg.fontSize = parseInt(m[1], 10) + } } break + } } }, updateEntityLabel() { @@ -8266,6 +8705,136 @@ export default { this.$emit('copy-route', ed.routeId); }, + /** 右键「拆分航段」:父组件拉取航点后进入两点选范围 */ + handleRouteSegmentSplitFromMenu() { + const ed = this.contextMenu.entityData; + if (!ed || ed.routeId == null || (ed.type !== 'route' && ed.type !== 'routeWaypoint')) { + this.contextMenu.visible = false; + return; + } + this.contextMenu.visible = false; + this.$emit('route-segment-pick', { routeId: ed.routeId, mode: 'split' }); + }, + + /** 右键「拆分复制」 */ + handleRouteSegmentCopyFromMenu() { + const ed = this.contextMenu.entityData; + if (!ed || ed.routeId == null || (ed.type !== 'route' && ed.type !== 'routeWaypoint')) { + this.contextMenu.visible = false; + return; + } + this.contextMenu.visible = false; + this.$emit('route-segment-pick', { routeId: ed.routeId, mode: 'copy' }); + }, + + /** + * 开始航段范围选取(依次点击两个航点,顺序不限) + * @param {string|number} routeId + * @param {Array} waypoints 已排序或与 seq 一致 + * @param {'split'|'copy'} mode + * @param {Object|null} routeStyle parseRouteStyle(attributes),用于预览主线与地图航线一致 + */ + startRouteSegmentPickMode(routeId, waypoints, mode, routeStyle) { + if (!this.viewer || !waypoints || waypoints.length < 2) return; + this.routeSegmentPickContext = null; + this.clearCopyPreview(); + if (typeof this.clearAddWaypointContext === 'function') this.clearAddWaypointContext(); + const sorted = waypoints.slice().sort((a, b) => { + const ds = (Number(a.seq) || 0) - (Number(b.seq) || 0); + if (ds !== 0) return ds; + return (Number(a.id) || 0) - (Number(b.id) || 0); + }); + this.routeSegmentPickContext = { + routeId, + waypoints: sorted, + mode, + firstIndex: null, + routeStyle: routeStyle || null + }; + }, + + /** 与 renderRouteWaypoints 一致:由航线 display 样式得到折线宽与材质 */ + buildRouteLineWidthAndMaterialFromStyle(style) { + const lineStyle = (style && style.line) ? style.line : {}; + const lineWidth = lineStyle.width != null ? lineStyle.width : 2; + const lineColor = lineStyle.color || '#64748b'; + const gapColor = lineStyle.gapColor != null ? lineStyle.gapColor : '#cbd5e1'; + const dashLen = lineStyle.dashLength != null ? lineStyle.dashLength : 20; + const useDash = (lineStyle.style || 'solid') === 'dash'; + const material = useDash + ? new Cesium.PolylineDashMaterialProperty({ + color: Cesium.Color.fromCssColorString(lineColor), + gapColor: Cesium.Color.fromCssColorString(gapColor), + dashLength: dashLen + }) + : Cesium.Color.fromCssColorString(lineColor); + return { width: lineWidth, material }; + }, + + /** 航段随鼠标平移预览:仅被选航段折线,CallbackProperty + 与航线一致的线型 */ + createRouteSegmentCopyPreviewEntities(routeStyle) { + if (!this.viewer || !this.copyPreviewWaypoints || this.copyPreviewWaypoints.length < 2 || !this.routeSegmentPlaceMeta) return; + if (this.copyPreviewEntity) { + this.viewer.entities.remove(this.copyPreviewEntity); + this.copyPreviewEntity = null; + } + const { width, material } = this.buildRouteLineWidthAndMaterialFromStyle(routeStyle); + const that = this; + this.copyPreviewEntity = this.viewer.entities.add({ + polyline: { + positions: new Cesium.CallbackProperty(() => { + const wps = that.copyPreviewWaypoints; + const mouse = that.copyPreviewMouseCartesian; + if (!wps || wps.length < 2 || !mouse) return []; + const first = wps[0]; + const firstLon = parseFloat(first.lng) * (Math.PI / 180); + const firstLat = parseFloat(first.lat) * (Math.PI / 180); + const carto = Cesium.Cartographic.fromCartesian(mouse); + const dLon = carto.longitude - firstLon; + const dLat = carto.latitude - firstLat; + return wps.map((wp) => + Cesium.Cartesian3.fromRadians( + parseFloat(wp.lng) * (Math.PI / 180) + dLon, + parseFloat(wp.lat) * (Math.PI / 180) + dLat, + Number(wp.alt) || 5000 + ) + ); + }, false), + width, + material, + arcType: Cesium.ArcType.NONE + } + }); + if (this.viewer.scene.requestRender) this.viewer.scene.requestRender(); + }, + + beginRouteSegmentMovePreview(routeId, fullWaypoints, lo, hi, mode, routeStyle) { + const slice = fullWaypoints.slice(lo, hi + 1); + if (slice.length < 2) { + this.$message && this.$message.warning('请至少选择 2 个航点'); + return; + } + if (mode === 'split') { + const remaining = fullWaypoints.length - slice.length; + if (remaining < 2) { + this.$message && this.$message.warning('拆分航段后原航线至少需保留 2 个航点,请缩小选取范围'); + return; + } + } + const removedWaypointIds = slice.map((w) => w.id).filter((id) => id != null); + if (this.copyPreviewEntity) { + this.viewer.entities.remove(this.copyPreviewEntity); + this.copyPreviewEntity = null; + } + this.copyPreviewWaypoints = slice.map((w) => ({ ...w })); + this.routeSegmentPlaceMeta = { mode, sourceRouteId: routeId, removedWaypointIds }; + this.copyPreviewMouseCartesian = this.viewer.camera.pickEllipsoid( + new Cesium.Cartesian2(this.viewer.scene.canvas.clientWidth / 2, this.viewer.scene.canvas.clientHeight / 2), + this.viewer.scene.globe.ellipsoid + ) || Cesium.Cartesian3.fromDegrees(parseFloat(slice[0].lng), parseFloat(slice[0].lat), Number(slice[0].alt) || 5000); + this.createRouteSegmentCopyPreviewEntities(routeStyle); + }, + /** 右键「单条航线推演」:弹出时间轴,仅推演该航线 */ handleSingleRouteDeductionFromMenu(routeId) { const ed = this.contextMenu.entityData; @@ -8280,6 +8849,7 @@ export default { /** 开始航线复制预览:整条航线跟随鼠标,左键放置后父组件弹窗保存 */ startRouteCopyPreview(waypoints) { if (!waypoints || waypoints.length < 2) return; + this.routeSegmentPickContext = null; this.clearCopyPreview(); this.copyPreviewWaypoints = waypoints; this.copyPreviewMouseCartesian = this.viewer.camera.pickEllipsoid( @@ -8288,8 +8858,9 @@ export default { ) || Cesium.Cartesian3.fromDegrees(parseFloat(waypoints[0].lng), parseFloat(waypoints[0].lat), Number(waypoints[0].alt) || 5000); this.updateCopyPreviewPolyline(); }, - /** 更新复制预览折线:以当前鼠标位置为起点偏移整条航线 */ + /** 更新复制预览折线:整条航线复制时重建实体;航段模式由 CallbackProperty 实时驱动,勿调此方法 */ updateCopyPreviewPolyline() { + if (this.routeSegmentPlaceMeta) return; if (!this.copyPreviewWaypoints || this.copyPreviewWaypoints.length < 2 || !this.copyPreviewMouseCartesian) return; const first = this.copyPreviewWaypoints[0]; const firstLon = parseFloat(first.lng) * (Math.PI / 180); @@ -8310,7 +8881,7 @@ export default { } this.copyPreviewEntity = this.viewer.entities.add({ polyline: { - positions: positions, + positions, width: 2, material: Cesium.Color.fromCssColorString('rgba(0, 120, 255, 0.75)'), arcType: Cesium.ArcType.NONE @@ -8325,6 +8896,7 @@ export default { } this.copyPreviewWaypoints = null; this.copyPreviewMouseCartesian = null; + this.routeSegmentPlaceMeta = null; }, /** 右键菜单:打开航点编辑(支持 dbId 或 waypointIndex) */ handleContextMenuOpenWaypointDialog(dbId, routeId, waypointIndex) { @@ -8435,38 +9007,13 @@ export default { this.addWaypointPreviewEntity = null; } this.addWaypointContext = null; + this.hoverTooltip.visible = false; }, - /** 为空域图形(多边形/矩形/圆形/扇形)添加或更新中心名称标签,微软雅黑、不加粗,清晰可读 */ + /** 为空域图形(多边形/矩形/圆形/扇形)添加或更新中心名称:屏幕 DOM 透明底标签 */ updateAirspaceEntityLabel(entityData) { if (!entityData || !entityData.entity || !['polygon', 'rectangle', 'circle', 'sector'].includes(entityData.type)) return - const entity = entityData.entity - const name = entityData.name - const font = '16px Microsoft YaHei' - const labelStyle = { - text: name || '', - font, - fillColor: Cesium.Color.fromCssColorString('#1a1a1a'), - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - backgroundColor: Cesium.Color.fromCssColorString('rgba(255, 255, 255, 0.95)'), - backgroundPadding: new Cesium.Cartesian2(10, 6), - disableDepthTestDistance: Number.POSITIVE_INFINITY - } - if (!name) { - entity.label = undefined - return - } - const that = this - const getCenter = () => { - const c = that.getAirspaceCenter(entityData) - return c || Cesium.Cartesian3.ZERO - } - entity.label = { - ...labelStyle, - position: new Cesium.CallbackProperty(getCenter, false) - } + this.registerMapTextForAirspace(entityData) }, /** 开始空域位置调整模式:右键菜单点击「调整位置」后进入,移动鼠标预览,左键确认、右键取消 */ @@ -9080,10 +9627,6 @@ export default { const routeId = ed.routeId const nextVisible = !(this.routeLabelVisible[routeId] !== false) this.$set(this.routeLabelVisible, routeId, nextVisible) - const labelEntity = this.viewer.entities.getById(`route-platform-label-${routeId}`) - if (labelEntity) { - labelEntity.show = nextVisible - } this.contextMenu.visible = false if (this.viewer.scene.requestRenderMode) this.viewer.scene.requestRender() }, @@ -9096,46 +9639,24 @@ export default { } const routeId = ed.routeId - // 读取标牌设置 - const labelEntity = this.viewer.entities.getById(`route-platform-label-${routeId}`) let fontSize = 16 let fontColor = '#333333' if (this.routeLabelStyles[routeId]) { fontSize = this.routeLabelStyles[routeId].fontSize; fontColor = this.routeLabelStyles[routeId].fontColor; - } else if (labelEntity && labelEntity.label) { - const now = Cesium.JulianDate.now() - const fontValue = labelEntity.label.font && labelEntity.label.font.getValue - ? labelEntity.label.font.getValue(now) - : labelEntity.label.font - if (fontValue && typeof fontValue === 'string') { - const match = fontValue.match(/(\d+)px/) - if (match) fontSize = parseInt(match[1], 10) - } - const fill = labelEntity.label.fillColor - if (fill) { - const c = fill.getValue ? fill.getValue(now) : fill - if (c && c.toCssColorString) { - fontColor = c.toCssColorString() - } - } + } + const pls = this.platformScreenLabelState[routeId] + if (pls) { + if (pls.fontSize != null) fontSize = Number(pls.fontSize) + if (pls.fontColor) fontColor = pls.fontColor } // 读取平台设置 const platformEntity = this.viewer.entities.getById(`route-platform-${routeId}`) let iconSize = 144 let iconColor = '#000000' - let platformName = '平台' // 默认名称 - - // 尝试获取平台名称 - if (labelEntity && labelEntity.label && labelEntity.label.text) { - const now = Cesium.JulianDate.now() - const text = labelEntity.label.text.getValue ? labelEntity.label.text.getValue(now) : labelEntity.label.text - if (text) platformName = text - } else if (ed.platformName) { - platformName = ed.platformName - } + let platformName = (pls && pls.name) || ed.platformName || '平台' if (platformEntity && platformEntity.billboard) { const now = Cesium.JulianDate.now() @@ -9208,29 +9729,13 @@ export default { const fontColor = this.editPlatformForm.fontColor || '#333333'; this.$set(this.routeLabelStyles, routeId, { fontSize, fontColor }); - // 更新标牌 - const labelEntity = this.viewer.entities.getById(`route-platform-label-${routeId}`) - if (labelEntity) { - if (labelEntity.billboard) { - const data = labelEntity.labelDataCache || { name: '平台', altitude: 0, speed: 0, headingDeg: 0 }; - const labelResult = this.createRoundedLabelCanvas({ - name: data.name, - altitude: data.altitude, - speed: data.speed, - heading: data.headingDeg, - fontSize: fontSize, - fontColor: fontColor - }); - labelEntity.billboard.image = new Cesium.ConstantProperty(labelResult.canvas); - labelEntity.billboard.scale = labelResult.scale; - } else if (labelEntity.label) { - labelEntity.label.font = `${fontSize}px Microsoft YaHei` - labelEntity.label.fillColor = Cesium.Color.fromCssColorString(fontColor) - - // 优化透明度:背景颜色透明度调低,看起来更清爽 - labelEntity.label.backgroundColor = Cesium.Color.fromCssColorString('rgba(255, 255, 255, 0.6)') - } - } + const st0 = this.platformScreenLabelState[routeId] || {} + this.$set(this.platformScreenLabelState, routeId, { + ...st0, + fontSize, + fontColor, + name: this.editPlatformForm.platformName != null ? this.editPlatformForm.platformName : (st0.name || '平台') + }) // 更新平台 const platformEntity = this.viewer.entities.getById(`route-platform-${routeId}`) @@ -9496,7 +10001,6 @@ export default { ensurePowerZoneForRoute(routeId, arg1, arg2, arg3, arg4, arg5) { if (!routeId || !this.viewer) return const platformEntity = this.viewer.entities.getById(`route-platform-${routeId}`) - const labelEntity = this.viewer.entities.getById(`route-platform-label-${routeId}`) if (!platformEntity) return let zoneId = 'legacy-power-1' let radiusKm = null @@ -9531,8 +10035,9 @@ export default { const center = platformEntity.position.getValue(now) // 航向:北为 0° 顺时针为正(东 90°);generateSectorPositions 中 0=东、π/2=北(逆时针),故中心角 = π/2 - 航向弧度 let headingRad = 0 - if (labelEntity && labelEntity.labelDataCache && labelEntity.labelDataCache.headingDeg != null) { - headingRad = (Number(labelEntity.labelDataCache.headingDeg) * Math.PI) / 180 + const pls = this.platformScreenLabelState[routeId] + if (pls && pls.headingDeg != null) { + headingRad = (Number(pls.headingDeg) * Math.PI) / 180 } const centerMathAngle = Math.PI / 2 - headingRad const startAngle = centerMathAngle + halfAngle @@ -10557,10 +11062,34 @@ export default { } }, + /** 框选右键菜单:删除当前框选内的全部房间平台图标 */ + deleteBoxSelectedPlatformsFromMenu() { + const list = [...(this.platformMultiSelected || [])] + this.contextMenu.visible = false + if (!list.length) return + const n = list.length + this.$confirm(`确定删除已框选的 ${n} 个平台图标?`, '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + .then(() => { + const ids = list.map(ed => ed.id).filter(Boolean) + ids.forEach(id => this.removeEntity(id)) + this.platformMultiSelected = [] + this.$message && this.$message.success(`已删除 ${n} 个平台图标`) + }) + .catch(() => {}) + }, + // 从右键菜单删除实体 deleteEntityFromContextMenu() { if (this.contextMenu.entityData) { const entityData = this.contextMenu.entityData + if (entityData.type === 'platformBoxSelection') { + this.contextMenu.visible = false + return + } if (entityData.isWhiteboard) { this.$emit('whiteboard-entity-deleted', entityData) if (entityData.entity) this.viewer.entities.remove(entityData.entity) @@ -10869,21 +11398,17 @@ export default { // 缓存样式(含探测区、威力区及显示开关) this.setPlatformStyle(routeId, style) - // 应用标牌样式 - const labelEntity = this.viewer.entities.getById(`route-platform-label-${routeId}`); - if (labelEntity) { - // 触发重绘(借助 updatePlatformPosition 中的逻辑) - // 这里手动设置一下样式,updatePlatformPosition 会在下次更新时使用 - this.$set(this.routeLabelStyles, routeId, { - fontSize: style.labelFontSize || 16, - fontColor: style.labelFontColor || '#333333' - }); - // 强制更新标牌内容 - if (labelEntity.labelDataCache) { - // 清除最后更新时间,强制刷新 - labelEntity._lastUpdateTime = 0; - this.updatePlatformPosition(routeId, entity.position.getValue(Cesium.JulianDate.now()), null, labelEntity.labelDataCache); - } + this.$set(this.routeLabelStyles, routeId, { + fontSize: style.labelFontSize || 16, + fontColor: style.labelFontColor || '#333333' + }) + const prevState = this.platformScreenLabelState[routeId] + if (prevState) { + this.$set(this.platformScreenLabelState, routeId, { + ...prevState, + fontSize: style.labelFontSize || 16, + fontColor: style.labelFontColor || '#333333' + }) } // 应用平台图标样式 @@ -10941,6 +11466,12 @@ export default { if (property === 'name' && ['polygon', 'rectangle', 'circle', 'sector'].includes(entityData.type)) { this.updateAirspaceEntityLabel(entityData) } + if (property === 'name' && entityData.type === 'powerZone') { + this.registerMapTextForPowerZone(entityData) + } + if (property === 'text' && entityData.type === 'text') { + this.registerMapTextForInsertedText(entityData) + } // 更新实体样式 this.updateEntityStyle(entityData) // 白板实体:通知父组件更新 contentByTime @@ -11028,6 +11559,10 @@ export default { if (index > -1) { const entity = this.allEntities[index] // 平台图标:移除伸缩框、探测区/威力区实体并清除选中;若已保存到服务端则通知父组件删除,并删除该实例的 Redis 样式避免幽灵区 + if (entity.type === 'point' && entity.id) { + this.ensureMapScreenDomLabelRegistry() + delete this._mapScreenDomLabelRegistry[`map-dom-mappoint-${entity.id}`] + } if (entity.type === 'platformIcon') { if (entity.serverId) { this.$emit('platform-icon-removed', { serverId: entity.serverId }) @@ -11049,6 +11584,15 @@ export default { } } } + if (entity.type === 'text' && entity.entity && entity.entity.id) { + this.unregisterMapTextDomLabel(`map-dom-maptext-text-${entity.entity.id}`) + } + if (['polygon', 'rectangle', 'circle', 'sector'].includes(entity.type)) { + this.unregisterMapTextDomLabel(`map-dom-maptext-airspace-${entity.id}`) + } + if (entity.type === 'powerZone') { + this.unregisterMapTextDomLabel(`map-dom-maptext-pz-${entity.id}`) + } // 从地图中移除 if (entity instanceof Cesium.Entity) { // 情况 A: 直接是 Cesium Entity 对象 @@ -11059,6 +11603,7 @@ export default { } // 移除线实体相关的点实体和段标签实体 if (entity.type === 'line') { + this.unregisterLineSegmentDomLabels(entity.id) if (entity.pointEntities) { entity.pointEntities.forEach(pointEntity => { this.viewer.entities.remove(pointEntity) @@ -11170,6 +11715,15 @@ export default { } } + if (item.type === 'text' && item.entity && item.entity.id) { + this.unregisterMapTextDomLabel(`map-dom-maptext-text-${item.entity.id}`) + } + if (['polygon', 'rectangle', 'circle', 'sector'].includes(item.type)) { + this.unregisterMapTextDomLabel(`map-dom-maptext-airspace-${item.id}`) + } + if (item.type === 'powerZone') { + this.unregisterMapTextDomLabel(`map-dom-maptext-pz-${item.id}`) + } // 删除非航线实体 if (entity) { this.viewer.entities.remove(entity); @@ -11177,6 +11731,7 @@ export default { // 移除线实体相关的点实体和段标签实体 if (item.type === 'line') { + this.unregisterLineSegmentDomLabels(item.id) if (item.pointEntities) { item.pointEntities.forEach(pointEntity => { this.viewer.entities.remove(pointEntity); @@ -11474,11 +12029,21 @@ export default { const toRemove = this.allEntities.filter(item => types.includes(item.type)) toRemove.forEach(item => { try { + if (item.type === 'text' && item.entity && item.entity.id) { + this.unregisterMapTextDomLabel(`map-dom-maptext-text-${item.entity.id}`) + } + if (['polygon', 'rectangle', 'circle', 'sector'].includes(item.type)) { + this.unregisterMapTextDomLabel(`map-dom-maptext-airspace-${item.id}`) + } + if (item.type === 'powerZone') { + this.unregisterMapTextDomLabel(`map-dom-maptext-pz-${item.id}`) + } let entity = item.entity || (item instanceof Cesium.Entity ? item : null) if (!entity && item.id) entity = this.viewer.entities.getById(item.id) if (entity) this.viewer.entities.remove(entity) if (item.type === 'powerZone' && item.centerEntity) this.viewer.entities.remove(item.centerEntity) if (item.type === 'line') { + this.unregisterLineSegmentDomLabels(item.id) if (item.pointEntities) item.pointEntities.forEach(pe => { try { this.viewer.entities.remove(pe) } catch (_) {} }) if (item.segmentLabelEntities) item.segmentLabelEntities.forEach(le => { try { this.viewer.entities.remove(le) } catch (_) {} }) } @@ -11526,6 +12091,13 @@ export default { /** 清除白板实体(id 以 wb_ 开头) */ clearWhiteboardEntities() { if (!this.viewer) return + if (this.whiteboardEntityDataMap) { + Object.values(this.whiteboardEntityDataMap).forEach((ed) => { + if (ed && ed.type === 'text' && ed.entity && ed.entity.id) { + this.unregisterMapTextDomLabel(`map-dom-maptext-text-${ed.entity.id}`) + } + }) + } const toRemove = [] this.viewer.entities.values.forEach(e => { if (e.id && String(e.id).startsWith('wb_')) toRemove.push(e) @@ -11557,6 +12129,9 @@ export default { Object.keys(this.whiteboardEntityDataMap).forEach(id => { if (!wantIds.has(id)) { const ed = this.whiteboardEntityDataMap[id] + if (ed && ed.type === 'text' && ed.entity && ed.entity.id) { + this.unregisterMapTextDomLabel(`map-dom-maptext-text-${ed.entity.id}`) + } if (ed && ed.entity) this.viewer.entities.remove(ed.entity) delete this.whiteboardEntityDataMap[id] } @@ -11637,6 +12212,9 @@ export default { } const existing = this.whiteboardEntityDataMap && this.whiteboardEntityDataMap[id] if (existing && existing.entity) { + if (existing.type === 'text' && existing.entity.id) { + this.unregisterMapTextDomLabel(`map-dom-maptext-text-${existing.entity.id}`) + } this.viewer.entities.remove(existing.entity) delete this.whiteboardEntityDataMap[id] } @@ -11766,17 +12344,13 @@ export default { entity = this.viewer.entities.add({ id: entityData.id, position: Cesium.Cartesian3.fromDegrees(lng, lat), - label: { - text, - font: (data.fontSize || 14) + 'px sans-serif', - fillColor: Cesium.Color.fromCssColorString(data.color || color), - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - style: Cesium.LabelStyle.FILL_AND_OUTLINE, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -10) - } + point: this.getInsertedTextPickPointGraphics() }) + entityData.text = text + entityData.lat = lat + entityData.lng = lng + entityData.color = data.color || color + entityData.font = (data.fontSize || 14) + 'px sans-serif' break } case 'sector': { @@ -11880,6 +12454,9 @@ export default { entityData.isWhiteboard = true this.whiteboardEntityDataMap = this.whiteboardEntityDataMap || {} this.whiteboardEntityDataMap[entityData.id] = entityData + if (entityData.type === 'text') { + this.registerMapTextForInsertedText(entityData) + } } }, /** 从房间/方案加载的 frontend_drawings JSON 恢复空域图形(先清空当前图形再导入;加载期间不触发自动保存) */ @@ -12364,36 +12941,28 @@ export default { const td = entityData.data if (!td || td.lat == null || td.lng == null) break const textPos = Cesium.Cartesian3.fromDegrees(td.lng, td.lat) + const tid = entityData.id != null ? String(entityData.id) : `text_imp_${Date.now()}` entity = this.viewer.entities.add({ + id: tid, position: textPos, - label: { - text: td.text != null ? td.text : (entityData.label || '文本'), - font: td.font || this.defaultStyles.text.font, - fillColor: Cesium.Color.fromCssColorString(color), - outlineColor: Cesium.Color.fromCssColorString('#e5e5e5'), - outlineWidth: 1, - style: Cesium.LabelStyle.FILL_AND_OUTLINE, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - backgroundColor: td.backgroundColor ? Cesium.Color.fromCssColorString(td.backgroundColor) : Cesium.Color.fromCssColorString(this.defaultStyles.text.backgroundColor), - backgroundPadding: new Cesium.Cartesian2(8, 5), - scaleByDistance: new Cesium.NearFarScalar(200, 1.12, 1200000, 0.72), - translucencyByDistance: new Cesium.NearFarScalar(300, 1.0, 600000, 0.88) - } + point: this.getInsertedTextPickPointGraphics() }) - this.allEntities.push({ + const textStr = td.text != null ? td.text : (entityData.label || '文本') + const textRow = { id: entity.id, type: 'text', lat: td.lat, lng: td.lng, - text: td.text, + text: textStr, position: textPos, entity, - color, - font: td.font, + color: td.color || color, + font: td.font || this.defaultStyles.text.font, backgroundColor: td.backgroundColor, label: entityData.label || '文本' - }) + } + this.allEntities.push(textRow) + this.registerMapTextForInsertedText(textRow) this.notifyDrawingEntitiesChanged() return } @@ -12451,10 +13020,9 @@ export default { }) const centerEntity = this.viewer.entities.add({ position: pzCenter, - point: { pixelSize: 8, color: Cesium.Color.RED, outlineColor: Cesium.Color.WHITE, outlineWidth: 2 }, - label: { text: pd.name || '威力区', font: '14px sans-serif', fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2, style: Cesium.LabelStyle.FILL_AND_OUTLINE, verticalOrigin: Cesium.VerticalOrigin.BOTTOM, pixelOffset: new Cesium.Cartesian2(0, -12) } + point: { pixelSize: 8, color: Cesium.Color.RED, outlineColor: Cesium.Color.WHITE, outlineWidth: 2 } }) - this.allEntities.push({ + const pzRow = { id: entity.id, type: 'powerZone', entity, @@ -12466,7 +13034,9 @@ export default { opacity: pd.opacity, borderColor: pd.borderColor, width: pd.width - }) + } + this.allEntities.push(pzRow) + this.registerMapTextForPowerZone(pzRow) this.notifyDrawingEntitiesChanged() return } @@ -12481,7 +13051,7 @@ export default { ...entityData.data } this.allEntities.push(pushed) - if (['polygon', 'circle'].includes(entityData.type) && pushed.name) { + if (['polygon', 'rectangle', 'circle', 'sector'].includes(entityData.type) && pushed.name) { this.updateAirspaceEntityLabel(pushed) } if (this.getDrawingEntityTypes().includes(entityData.type)) this.notifyDrawingEntitiesChanged() @@ -12830,11 +13400,368 @@ export default { this.scaleBarCleanup = null } + if (this._mapScreenDomLabelPostRender && this.viewer && this.viewer.scene) { + try { + this.viewer.scene.postRender.removeEventListener(this._mapScreenDomLabelPostRender) + } catch (e) {} + } + this._mapScreenDomLabelPostRender = null + if (this._mapScreenDomLabelRegistry) { + this._mapScreenDomLabelRegistry = {} + } + this.screenDomLabelItems = [] + if (this.viewer) { this.viewer.destroy() this.viewer = null } }, + + /** 初始化屏幕 DOM 标签注册表(非响应式,避免 Vue 遍历) */ + ensureMapScreenDomLabelRegistry() { + if (!this._mapScreenDomLabelRegistry) { + this._mapScreenDomLabelRegistry = Object.create(null) + } + }, + + unregisterLineSegmentDomLabels(lineId) { + this.ensureMapScreenDomLabelRegistry() + const prefix = `map-dom-seg-${lineId}-` + Object.keys(this._mapScreenDomLabelRegistry).forEach((k) => { + if (k.startsWith(prefix)) { + delete this._mapScreenDomLabelRegistry[k] + } + }) + }, + + unregisterWaypointMapDomLabelsForRoute(routeId) { + this.ensureMapScreenDomLabelRegistry() + const prefix = `map-dom-wp-${routeId}-` + Object.keys(this._mapScreenDomLabelRegistry).forEach((k) => { + if (k.startsWith(prefix)) { + delete this._mapScreenDomLabelRegistry[k] + } + }) + }, + + unregisterPlatformMapDomLabel(routeId) { + this.ensureMapScreenDomLabelRegistry() + delete this._mapScreenDomLabelRegistry[`map-dom-pf-${routeId}`] + }, + + unregisterDrawingPointDomLabelsForEntities(pointEntities) { + if (!pointEntities || !pointEntities.length) return + this.ensureMapScreenDomLabelRegistry() + pointEntities.forEach((pe) => { + const pid = pe && pe.id != null ? String(pe.id) : '' + if (pid) { + delete this._mapScreenDomLabelRegistry[`map-dom-drawlpt-${pid}`] + } + }) + }, + + registerPlatformMapDomLabel(routeId) { + this.ensureMapScreenDomLabelRegistry() + this._mapScreenDomLabelRegistry[`map-dom-pf-${routeId}`] = { + kind: 'platform', + routeId + } + }, + + registerWaypointMapDomLabel(routeId, wpDbId, text, pixelSize, wp, entityIdSuffix = '') { + this.ensureMapScreenDomLabelRegistry() + const regKey = `${wpDbId}${entityIdSuffix}` + const id = `map-dom-wp-${routeId}-${regKey}` + const fontSize = + wp && wp.labelFontSize != null ? Math.min(28, Math.max(10, Number(wp.labelFontSize))) : 14 + const fillColor = (wp && wp.labelColor) || '#334155' + this._mapScreenDomLabelRegistry[id] = { + kind: 'waypoint', + entityId: `wp_${routeId}_${wpDbId}${entityIdSuffix}`, + pixelOffset: { x: 0, y: -Math.max(14, pixelSize + 8) }, + text: text || '', + fontSize, + fillColor + } + }, + + getLineEntityPositionsForDomLabels(entityData, time) { + if (!entityData) return null + if (entityData.pointEntities && entityData.pointEntities.length >= 2) { + const arr = entityData.pointEntities + .map((pe) => (pe.position && pe.position.getValue ? pe.position.getValue(time) : null)) + .filter(Boolean) + if (arr.length >= 2) { + return arr + } + } + let positions = entityData.positions + if (!positions && entityData.points && entityData.points.length >= 2) { + positions = entityData.points.map((p) => Cesium.Cartesian3.fromDegrees(p.lng, p.lat)) + } + return positions + }, + + /** 从 font 字符串解析 px 字号,供插入文字 DOM 标签使用 */ + parseTextEntityFontSizePx(entityData) { + if (!entityData || !entityData.font) return 14 + const m = String(entityData.font).match(/(\d+)px/) + return m ? parseInt(m[1], 10) : 14 + }, + + /** 空域多边形/矩形/圆/扇形中心名称:透明底 DOM */ + registerMapTextForAirspace(entityData) { + if (!entityData || !['polygon', 'rectangle', 'circle', 'sector'].includes(entityData.type)) return + this.ensureMapScreenDomLabelRegistry() + const key = `map-dom-maptext-airspace-${entityData.id}` + if (!entityData.name || !String(entityData.name).trim()) { + delete this._mapScreenDomLabelRegistry[key] + if (entityData.entity) entityData.entity.label = undefined + return + } + if (entityData.entity) entityData.entity.label = undefined + this._mapScreenDomLabelRegistry[key] = { + kind: 'mapText', + airspaceLabelForId: entityData.id, + text: String(entityData.name), + fontSize: 16, + fontWeight: '500', + color: '#1a1a1a', + transform: 'translate(-50%, -50%)', + pixelOffset: { x: 0, y: 0 } + } + }, + + /** 地图插入文字实体:透明底 DOM */ + registerMapTextForInsertedText(entityData) { + if (!entityData || entityData.type !== 'text' || !entityData.entity || !entityData.entity.id) return + this.ensureMapScreenDomLabelRegistry() + const key = `map-dom-maptext-text-${entityData.entity.id}` + this._mapScreenDomLabelRegistry[key] = { + kind: 'mapText', + anchorEntityId: entityData.entity.id, + text: entityData.text != null ? String(entityData.text) : '', + fontSize: this.parseTextEntityFontSizePx(entityData), + fontWeight: '600', + color: entityData.color || '#1a1a1a', + transform: 'translate(-50%, -50%)', + pixelOffset: { x: 0, y: 0 } + } + }, + + /** 威力区圆心名称:透明底 DOM */ + registerMapTextForPowerZone(entityData) { + if (!entityData || entityData.type !== 'powerZone') return + this.ensureMapScreenDomLabelRegistry() + const key = `map-dom-maptext-pz-${entityData.id}` + if (entityData.centerEntity) entityData.centerEntity.label = undefined + const name = + entityData.name && String(entityData.name).trim() + ? String(entityData.name) + : '威力区' + this._mapScreenDomLabelRegistry[key] = { + kind: 'mapText', + powerZoneDataId: entityData.id, + text: name, + fontSize: 14, + fontWeight: '600', + color: '#1a1a1a', + transform: 'translate(-50%, -100%)', + pixelOffset: { x: 0, y: -14 } + } + }, + + unregisterMapTextDomLabel(key) { + if (this._mapScreenDomLabelRegistry && this._mapScreenDomLabelRegistry[key]) { + delete this._mapScreenDomLabelRegistry[key] + } + }, + + syncMapScreenDomLabels() { + if (!this.viewer || !this.viewer.scene) return + const scene = this.viewer.scene + const time = this.viewer.clock.currentTime + this.ensureMapScreenDomLabelRegistry() + const items = [] + const reg = this._mapScreenDomLabelRegistry + for (const id of Object.keys(reg)) { + const r = reg[id] + let world + let visible = true + let text = '' + let kind = r.kind || 'plain' + let themeClass = 'map-screen-dom-label--tooltip' + let transform = 'translate(-50%, -50%)' + let pixelOffset = r.pixelOffset || { x: 0, y: 0 } + let titleSizePx = 16 + let statSizePx = 14 + let statColor = '#ffffff' + let name = '' + let altitude = 0 + let speed = 0 + let heading = 0 + let fontSize = 14 + let fillColor = '#334155' + let transparentTextStyle = {} + let multiline = false + + if (r.kind === 'platform') { + const st = this.platformScreenLabelState[r.routeId] + visible = st != null && this.routeLabelVisible[r.routeId] !== false + if (st) { + name = st.name || '平台' + altitude = Math.round(Number(st.altitude || 0)) + speed = Math.round(Number(st.speed || 0)) + const hdg = Number(st.headingDeg || 0) + heading = Math.round(((hdg % 360) + 360) % 360) + const base = Number(st.fontSize) || 16 + // 白底标牌视觉更小:相对用户设置字号约 72%,并收紧上下限 + titleSizePx = Math.max(9, Math.min(22, Math.round(base * 0.72))) + statSizePx = Math.max(8, Math.min(19, titleSizePx - 1)) + statColor = '#1a1a1a' + } else { + visible = false + } + transform = 'translate(-50%, -100%)' + pixelOffset = { x: 0, y: -32 } + themeClass = 'map-screen-dom-label--platform-card map-screen-dom-label--platform' + const e = this.viewer.entities.getById(`route-platform-${r.routeId}`) + world = e && e.position && e.position.getValue(time) + } else if (r.kind === 'waypoint') { + const e = this.viewer.entities.getById(r.entityId) + world = e && e.position && e.position.getValue(time) + text = r.text || '' + fontSize = r.fontSize || 14 + fillColor = r.fillColor || '#334155' + transform = 'translate(-50%, -100%)' + themeClass = 'map-screen-dom-label--maptext' + transparentTextStyle = { + fontSize: fontSize + 'px', + color: fillColor, + fontWeight: '600', + textShadow: + '0 0 2px #fff, 0 0 2px #fff, 0 0 2px #fff, 0 1px 2px rgba(0,0,0,0.35)' + } + } else if (r.kind === 'mapText') { + if (r.anchorEntityId) { + const e = this.viewer.entities.getById(r.anchorEntityId) + world = e && e.position && e.position.getValue(time) + } else if (r.airspaceLabelForId != null) { + const ed = this.allEntities.find((e) => e.id === r.airspaceLabelForId) + world = ed ? this.getAirspaceCenter(ed) : undefined + } else if (r.powerZoneDataId != null) { + const ed = this.allEntities.find((e) => e.id === r.powerZoneDataId && e.type === 'powerZone') + world = + ed && ed.centerEntity && ed.centerEntity.position + ? ed.centerEntity.position.getValue(time) + : undefined + } else { + world = undefined + } + text = r.text != null ? String(r.text) : '' + if (!text.trim()) { + visible = false + } + fontSize = r.fontSize || 14 + const fw = r.fontWeight || '600' + const tc = r.color || '#1a1a1a' + transform = r.transform || 'translate(-50%, -50%)' + pixelOffset = r.pixelOffset || { x: 0, y: 0 } + themeClass = 'map-screen-dom-label--maptext' + transparentTextStyle = { + fontSize: fontSize + 'px', + color: tc, + fontWeight: fw, + textShadow: + '0 0 2px #fff, 0 0 2px #fff, 0 0 2px #fff, 0 1px 2px rgba(0,0,0,0.35)' + } + } else if (r.kind === 'segment') { + const positions = this.getLineEntityPositionsForDomLabels( + this.allEntities.find((e) => e.id === r.lineId && e.type === 'line'), + time + ) + world = + positions && positions.length > r.segmentVertexIndex + ? positions[r.segmentVertexIndex] + : undefined + text = r.text || '' + multiline = !!r.multiline + transform = 'translate(0, -50%)' + themeClass = 'map-screen-dom-label--tooltip' + } else if (r.kind === 'drawPoint') { + const e = this.viewer.entities.getById(r.entityId) + world = e && e.position && e.position.getValue(time) + text = r.text || '' + transform = r.centered ? 'translate(-50%, -50%)' : 'translate(0, -50%)' + themeClass = 'map-screen-dom-label--tooltip' + } else { + visible = false + } + + if (!visible) { + items.push({ + id, + visible: false, + kind, + multiline, + wrapperStyle: {} + }) + continue + } + if (!world) { + items.push({ + id, + visible: false, + kind, + multiline, + wrapperStyle: {} + }) + continue + } + let scr + try { + scr = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world) + } catch (err) { + scr = undefined + } + if (!scr || !Cesium.defined(scr)) { + items.push({ + id, + visible: false, + kind, + multiline, + wrapperStyle: {} + }) + continue + } + const left = scr.x + pixelOffset.x + const top = scr.y + pixelOffset.y + items.push({ + id, + visible: true, + kind, + multiline, + themeClass, + wrapperStyle: { + left: left + 'px', + top: top + 'px', + transform, + zIndex: 9998 + }, + text, + name, + altitude, + speed, + heading, + titleSizePx, + statSizePx, + statColor, + transparentTextStyle + }) + } + this.screenDomLabelItems = items + }, + // 查找鼠标悬停在线段的哪一段 findClosestSegment(positions, mousePosition) { if (!positions || positions.length < 2) { @@ -12912,8 +13839,11 @@ export default { if (!position) return; this.powerZoneCenter = position; - + this.entityCounter++ + const pzDataId = this.entityCounter + this._pendingPowerZoneDataId = pzDataId this.powerZoneCenterEntity = this.viewer.entities.add({ + id: `pz_c_${pzDataId}`, position: position, point: { pixelSize: 10, @@ -12921,17 +13851,6 @@ export default { outlineColor: Cesium.Color.WHITE, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY - }, - label: { - text: '', - font: '14px Microsoft YaHei', - fillColor: Cesium.Color.WHITE, - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - style: Cesium.LabelStyle.FILL_AND_OUTLINE, - verticalOrigin: Cesium.VerticalOrigin.TOP, - pixelOffset: new Cesium.Cartesian2(0, -20), - disableDepthTestDistance: Number.POSITIVE_INFINITY } }); @@ -12965,8 +13884,11 @@ export default { } }); - this.allEntities.push({ - id: ++this.entityCounter, + const pzDataId = + this._pendingPowerZoneDataId != null ? this._pendingPowerZoneDataId : ++this.entityCounter + this._pendingPowerZoneDataId = null + const pzRow = { + id: pzDataId, type: 'powerZone', entity: this.powerZoneCircleEntity, centerEntity: this.powerZoneCenterEntity, @@ -12977,12 +13899,10 @@ export default { opacity: 0, borderColor: '#FF0000', width: 2 - }); - this.notifyDrawingEntitiesChanged(); - - if (this.powerZoneCenterEntity && this.powerZoneCenterEntity.label) { - this.powerZoneCenterEntity.label.text = radiusData.name; } + this.allEntities.push(pzRow) + this.registerMapTextForPowerZone(pzRow) + this.notifyDrawingEntitiesChanged(); this.isDrawing = false; this.viewer.canvas.style.cursor = 'default'; @@ -13048,6 +13968,112 @@ export default { white-space: nowrap; } +.platform-box-select-hud { + position: absolute; + top: 72px; + left: 50%; + transform: translateX(-50%); + z-index: 99; + max-width: min(94vw, 520px); + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + pointer-events: none; +} + +.platform-box-select-tip { + background: rgba(0, 100, 70, 0.94); + color: #fff; + padding: 8px 20px; + border-radius: 8px; + font-size: 14px; + line-height: 1.4; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.22); + text-align: center; + white-space: nowrap; +} + +.platform-box-select-panel { + background: rgba(255, 255, 255, 0.96); + color: #1e293b; + border-radius: 8px; + padding: 10px 14px 12px; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.15); + border: 1px solid rgba(0, 136, 255, 0.25); + max-height: 220px; + display: flex; + flex-direction: column; + text-align: left; +} + +.platform-box-select-panel--empty { + max-height: none; + font-size: 13px; + color: #64748b; + line-height: 1.5; +} + +.platform-box-select-panel-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 8px; + color: #0f172a; + flex-shrink: 0; +} + +.platform-box-select-panel-list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.platform-box-select-panel-list li { + display: flex; + align-items: baseline; + gap: 6px; + padding: 4px 0; + font-size: 13px; + border-bottom: 1px solid rgba(226, 232, 240, 0.9); +} + +.platform-box-select-panel-list li:last-child { + border-bottom: none; +} + +.platform-box-select-panel-idx { + color: #0088ff; + font-weight: 600; + flex-shrink: 0; + min-width: 1.5em; +} + +.platform-box-select-panel-name { + word-break: break-all; +} + +.platform-box-select-layer { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 88; + overflow: hidden; +} + +.platform-box-select-rect { + position: absolute; + box-sizing: border-box; + border: 2px dashed rgba(0, 136, 255, 0.92); + background: rgba(0, 136, 255, 0.14); + pointer-events: none; +} + /* 航线复制预览提示 */ .copy-route-tip { position: absolute; diff --git a/ruoyi-ui/src/views/childRoom/LeftMenu.vue b/ruoyi-ui/src/views/childRoom/LeftMenu.vue index 62b0775..ec16c71 100644 --- a/ruoyi-ui/src/views/childRoom/LeftMenu.vue +++ b/ruoyi-ui/src/views/childRoom/LeftMenu.vue @@ -29,7 +29,7 @@ v-for="item in localMenuItems" :key="item.id" class="menu-item" - :class="{ active: activeMenu === item.id, 'dragging': isDragging, 'edit-mode': isEditMode }" + :class="{ active: isItemActive(item), 'dragging': isDragging, 'edit-mode': isEditMode }" @click="handleSelectMenu(item)" @contextmenu.prevent="handleRightClick(item)" :title="item.name" @@ -143,6 +143,13 @@ export default { return icon && typeof icon === 'string' && !icon.startsWith('el-icon-') }, + /** 框选等项通过图标编辑添加时 id 为随机字符串,activeMenu 固定为 platformBoxSelect,需按 action 匹配高亮 */ + isItemActive(item) { + if (this.activeMenu === item.id) return true + if (this.activeMenu === 'platformBoxSelect' && item.action === 'platformBoxSelect') return true + return false + }, + handleHide() { this.$emit('hide') }, diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index 7c06b3a..f7b4944 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -22,6 +22,8 @@ :route-locked-by-other-ids="routeLockedByOtherRouteIds" :deduction-time-minutes="deductionMinutesFromK" :room-id="currentRoomId" + :platform-box-select-mode="platformBoxSelectMode" + @request-exit-platform-box-select="onRequestExitPlatformBoxSelect" @draw-complete="handleMapDrawComplete" @route-lock-changed="handleRouteLockChanged" @drawing-points-update="missionDrawingPointsCount = $event" @@ -30,6 +32,8 @@ @open-waypoint-dialog="handleOpenWaypointEdit" @open-route-dialog="handleOpenRouteEdit" @copy-route="handleCopyRoute" + @route-segment-pick="handleRouteSegmentPick" + @route-segment-placed="handleRouteSegmentPlaced" @single-route-deduction="handleSingleRouteDeduction" @route-copy-placed="handleRouteCopyPlaced" @add-waypoint-at="handleAddWaypointAt" @@ -39,6 +43,7 @@ @missile-deleted="handleMissileDeleted" @scale-click="handleScaleClick" @platform-icon-updated="onPlatformIconUpdated" + @platform-icons-batch-updated="onPlatformIconsBatchUpdated" @platform-icon-removed="onPlatformIconRemoved" @viewer-ready="onViewerReady" @drawing-entities-changed="onDrawingEntitiesChanged" @@ -68,7 +73,7 @@
- + @@ -559,6 +564,8 @@ export default { return { drawDom:false, airspaceDrawDom:false, + /** 左侧菜单「框选平台」:多选房间平台图标并整体平移 */ + platformBoxSelectMode: false, /** 是否允许地图拖动(由顶部小手图标切换,默认关闭) */ mapDragEnabled: false, // 在线成员弹窗 @@ -589,6 +596,8 @@ export default { showNameDialog: false, newRouteName: '', tempMapPoints: [], + /** 拆分航段:保存新航线成功后从原航线删除这些航点 id */ + pendingSegmentSplitAfterNewRoute: null, /** 从平台右键「在此之前/在此之后插入航线」完成时传入,保存航线时用作 platformId */ tempMapPlatform: null, /** 保存新航线弹窗内选择的方案 ID(弹窗内可直接选方案,无需先展开侧边) */ @@ -643,6 +652,7 @@ export default { { id: '4t', name: '4T', icon: 'T' }, { id: 'start', name: '冲突', icon: 'chongtu' }, { id: 'insert', name: '平台', icon: 'el-icon-s-platform' }, + { id: 'platformBoxSelect', name: '框选平台', icon: 'el-icon-crop', action: 'platformBoxSelect' }, { id: 'pattern', name: '空域', icon: 'ky' }, { id: 'deduction', name: '推演', icon: 'el-icon-video-play' }, { id: 'modify', name: '测距', icon: 'cj' }, @@ -670,6 +680,7 @@ export default { }, // 顶部导航菜单项(用于图标选择)- 只显示指定的菜单项 topNavItems: [ + { id: 'platformBoxSelect', name: '框选平台', icon: 'el-icon-crop' }, { id: 'routeEdit', name: '航线编辑', icon: 'el-icon-edit-outline' }, { id: 'militaryMarking', name: '军事标绘', icon: 'el-icon-crop' }, { id: 'attributeEdit', name: '属性修改', icon: 'el-icon-setting' }, @@ -1584,11 +1595,58 @@ export default { /** 复制航线已放置:用当前偏移后的航点打开「保存新航线」弹窗 */ handleRouteCopyPlaced(points) { + this.pendingSegmentSplitAfterNewRoute = null; this.tempMapPoints = points || []; this.tempMapPlatform = null; this.showNameDialog = true; }, + /** 右键拆分航段/拆分复制:拉取航点后进入地图两点选范围 */ + async handleRouteSegmentPick({ routeId, mode }) { + if (routeId == null) return; + if (this.routeLocked[routeId]) { + this.$message.info('该航线已上锁,请先解锁后再操作'); + return; + } + if (this.isRouteLockedByOther(routeId)) { + this.$message.warning('该航线正被其他成员编辑,请稍后再试'); + return; + } + try { + const res = await getRoutes(routeId); + if (res.code !== 200 || !res.data) { + this.$message.error('获取航线数据失败'); + return; + } + const waypoints = res.data.waypoints || []; + if (waypoints.length < 2) { + this.$message.warning('航线航点不足,无法选取航段'); + return; + } + if (this.$refs.cesiumMap && typeof this.$refs.cesiumMap.startRouteSegmentPickMode === 'function') { + const routeStyle = this.parseRouteStyle(res.data.attributes); + this.$refs.cesiumMap.startRouteSegmentPickMode(routeId, waypoints, mode, routeStyle); + } + } catch (e) { + this.$message.error('获取航线数据失败'); + console.error(e); + } + }, + + /** 航段预览已放置:打开保存弹窗;拆分航段时记下待从原航线删除的航点 id */ + handleRouteSegmentPlaced(payload) { + const { mode, sourceRouteId, removedWaypointIds, points } = payload || {}; + if (!points || points.length < 2) return; + this.tempMapPoints = points; + this.tempMapPlatform = null; + if (mode === 'split' && sourceRouteId != null && removedWaypointIds && removedWaypointIds.length) { + this.pendingSegmentSplitAfterNewRoute = { sourceRouteId, waypointIds: removedWaypointIds }; + } else { + this.pendingSegmentSplitAfterNewRoute = null; + } + this.showNameDialog = true; + }, + /** 地图上拖拽航点结束:将新位置写回数据库并刷新显示 */ async handleWaypointPositionChanged({ dbId, routeId, lat, lng, alt }) { if (this.isRouteLockedByOther(routeId)) { @@ -2561,6 +2619,7 @@ export default { this.drawDom = false; return; } + this.pendingSegmentSplitAfterNewRoute = null; this.tempMapPoints = points; this.tempMapPlatform = (options && (options.platformId != null || options.platform)) ? options : null; this.showNameDialog = true; @@ -2570,6 +2629,12 @@ export default { this.saveDialogScenarioId = this.selectedPlanId || (this.plans[0] && this.plans[0].id) || null; }, + onSaveNewRouteDialogClose() { + this.tempMapPlatform = null; + this.saveDialogScenarioId = null; + this.pendingSegmentSplitAfterNewRoute = null; + }, + openAddHoldDuringDrawing() { this.addHoldContext = { mode: 'drawing' }; this.addHoldForm = { holdType: 'hold_circle', edgeLengthKm: 20, clockwise: true, startTime: '', startTimeMinutes: 60 }; @@ -2644,6 +2709,21 @@ export default { return; } + const pendingSplit = this.pendingSegmentSplitAfterNewRoute; + if (pendingSplit && pendingSplit.sourceRouteId != null && Array.isArray(pendingSplit.waypointIds) && pendingSplit.waypointIds.length > 0) { + const roomIdParam = this.currentRoomId != null ? { roomId: this.currentRoomId } : {}; + try { + for (const wid of pendingSplit.waypointIds) { + await delWaypoints(wid, roomIdParam); + } + this.wsConnection?.sendSyncWaypoints?.(pendingSplit.sourceRouteId); + } catch (splitErr) { + console.error(splitErr); + this.$message.warning('新航线已保存,但从原航线切除所选航段失败,请手动删除原航线上的对应航点'); + } + } + this.pendingSegmentSplitAfterNewRoute = null; + // 1. 更新当前选中详情 this.selectedRouteId = newRouteId; this.selectedRouteDetails = JSON.parse(JSON.stringify(savedRoute)); @@ -3359,7 +3439,8 @@ export default { 'toggleRoute': () => this.toggleRoute(), 'layerFavorites': () => this.layerFavorites(), 'routeFavorites': () => this.routeFavorites(), - 'refresh': () => this.captureMapScreenshot() + 'refresh': () => this.captureMapScreenshot(), + 'platformBoxSelect': () => this.togglePlatformBoxSelectMenu() } if (actionMap[actionId]) { @@ -3367,6 +3448,27 @@ export default { } }, + /** 左侧「框选平台」:与菜单项 id 无关,统一用 action: platformBoxSelect 触发(含图标编辑添加的随机 id 项) */ + togglePlatformBoxSelectMenu() { + this.platformBoxSelectMode = !this.platformBoxSelectMode + this.activeMenu = this.platformBoxSelectMode ? 'platformBoxSelect' : '' + if (!this.platformBoxSelectMode) { + if (this.$refs.cesiumMap && typeof this.$refs.cesiumMap.exitPlatformBoxSelectMode === 'function') { + this.$refs.cesiumMap.exitPlatformBoxSelectMode() + } + } else { + this.drawDom = false + this.airspaceDrawDom = false + this.isRightPanelHidden = true + } + }, + + /** 地图内右键退出框选(子组件 emit) */ + onRequestExitPlatformBoxSelect() { + this.platformBoxSelectMode = false + this.activeMenu = '' + }, + /** 截图:隐藏上下左右菜单只保留地图,用 postRender + readPixels 避免 WebGL 缓冲被清空导致黑屏 */ async captureMapScreenshot() { const cm = this.$refs.cesiumMap @@ -4062,6 +4164,27 @@ export default { }).catch(() => {}) }, 500) }, + /** 框选整体拖拽结束:批量写库(避免多次 emit 被防抖合并为只保存最后一个) */ + onPlatformIconsBatchUpdated(list) { + if (!list || !list.length) return + const payloads = list.filter(e => e && e.serverId) + if (!payloads.length) return + Promise.all( + payloads.map(ed => + updateRoomPlatformIcon({ + id: ed.serverId, + lng: ed.lng, + lat: ed.lat, + heading: ed.heading != null ? ed.heading : 0, + iconScale: ed.iconScale != null ? ed.iconScale : 1 + }) + ) + ) + .then(() => { + this.wsConnection?.sendSyncPlatformIcons?.() + }) + .catch(() => {}) + }, /** 平台图标从地图删除时同步删除服务端记录 */ onPlatformIconRemoved({ serverId }) { if (!serverId) return @@ -4218,6 +4341,21 @@ export default { }, selectMenu(item) { + // 带 action 的项会先触发 handleMenuAction 再进此处;框选已在 handleMenuAction 中切换,避免重复 + if (item.action === 'platformBoxSelect') { + return + } + // 旧版菜单数据可能仅有 id、无 action + if (item.id === 'platformBoxSelect') { + this.togglePlatformBoxSelectMenu() + return + } + if (this.platformBoxSelectMode) { + this.platformBoxSelectMode = false + if (this.$refs.cesiumMap && typeof this.$refs.cesiumMap.exitPlatformBoxSelectMode === 'function') { + this.$refs.cesiumMap.exitPlatformBoxSelectMode() + } + } this.activeMenu = item.id; if (item.action) { this.handleMenuAction(item.action)