From 7678960b95e0ef2bc1ad45cac22a05f5ab2939b5 Mon Sep 17 00:00:00 2001 From: menghao <1584479611@qq.com> Date: Mon, 9 Mar 2026 09:03:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E7=9B=98=E6=97=8B=E5=9C=86=E5=BD=A2?= =?UTF-8?q?=E3=80=81=E6=A4=AD=E5=9C=86=E8=BD=A8=E8=BF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-ui/.env.development | 2 +- ruoyi-ui/src/views/cesiumMap/index.vue | 758 ++++++++++++++++++++++++--------- ruoyi-ui/src/views/childRoom/index.vue | 226 ++++++++-- ruoyi-ui/vue.config.js | 2 +- 4 files changed, 736 insertions(+), 252 deletions(-) diff --git a/ruoyi-ui/.env.development b/ruoyi-ui/.env.development index 0e7127a..13aa707 100644 --- a/ruoyi-ui/.env.development +++ b/ruoyi-ui/.env.development @@ -8,7 +8,7 @@ ENV = 'development' VUE_APP_BASE_API = '/dev-api' # 访问地址(绕过 /dev-api 代理,用于解决静态资源/图片访问 401 认证问题) -VUE_APP_BACKEND_URL = 'http://127.0.0.1:8080' +VUE_APP_BACKEND_URL = 'http://192.168.1.107:8080' # 路由懒加载 VUE_CLI_BABEL_TRANSPILE_MODULES = true diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 7e35d8f..9f42d36 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -571,7 +571,11 @@ export default { addWaypointPreviewEntity: null, // 空域位置调整:{ entityData },预览实体用 CallbackProperty 实时跟随鼠标(与测距绘制一致) airspacePositionEditContext: null, - airspacePositionEditPreviewEntity: null + airspacePositionEditPreviewEntity: null, + // 盘旋最小尺寸(米):圆半径 3km,椭圆长半轴 5km、短半轴 3.5km + MIN_HOLD_RADIUS_M: 3000, + MIN_ELLIPSE_SEMI_MAJOR_M: 5000, + MIN_ELLIPSE_SEMI_MINOR_M: 3500 } }, components: { @@ -638,6 +642,10 @@ export default { } }, computed: { + /** 当前房间 ID:优先 prop,否则从路由或父组件取,避免 roomId is not defined */ + effectiveRoomId() { + return this.roomId != null ? this.roomId : (this.$route && this.$route.query && this.$route.query.roomId) || (this.$parent && this.$parent.currentRoomId) || null + }, contextMenuZoneDetectionVisible() { const ed = this.contextMenu && this.contextMenu.entityData if (!ed) return true @@ -2011,6 +2019,8 @@ export default { }); if (!this._routeWaypointIdsByRoute) this._routeWaypointIdsByRoute = {}; this._routeWaypointIdsByRoute[routeId] = waypoints.map((wp) => wp.id); + if (!this._routeHoldRadiiByRoute) this._routeHoldRadiiByRoute = {}; + if (!this._routeHoldEllipseParamsByRoute) this._routeHoldEllipseParamsByRoute = {}; // 判断航点 i 是否为“转弯半径”航点(将用弧线两端两个点替代中心点);非首尾默认 45° 坡度 const isTurnWaypointWithArc = (i) => { if (i < 1 || i >= waypoints.length - 1) return false; @@ -2021,9 +2031,13 @@ export default { let nextLogical = nextPos; if (this.isHoldWaypoint(waypoints[i + 1])) { const holdParams = this.parseHoldParams(waypoints[i + 1]); + const clock = holdParams && holdParams.clockwise !== false; + const r = holdParams && holdParams.radius != null ? Math.max(this.MIN_HOLD_RADIUS_M, holdParams.radius) : this.MIN_HOLD_RADIUS_M; + const smj = Math.max(this.MIN_ELLIPSE_SEMI_MAJOR_M, holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500); + const smn = Math.max(this.MIN_ELLIPSE_SEMI_MINOR_M, holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300); nextLogical = holdParams && holdParams.radius != null - ? this.getCircleEntryPoint(originalPositions[i + 1], originalPositions[i], holdParams.radius) - : this.getEllipseEntryPoint(originalPositions[i + 1], originalPositions[i], holdParams.semiMajor ?? 500, holdParams.semiMinor ?? 300, ((holdParams.headingDeg || 0) * Math.PI) / 180); + ? this.getCircleTangentEntryPoint(originalPositions[i + 1], originalPositions[i], r, clock) + : this.getEllipseTangentEntryPoint(originalPositions[i + 1], originalPositions[i], smj, smn, ((holdParams.headingDeg || 0) * Math.PI) / 180, clock); } return !!nextLogical; }; @@ -2100,7 +2114,7 @@ export default { const heading = this.computeHeadingFromPositions(pathData.path[0], pathData.path[1]); if (heading !== undefined) initialRotation = Math.PI / 2 - heading; } - const currentRoomId = this.$route.query.roomId || (this.$parent && this.$parent.currentRoomId); + const currentRoomId = this.effectiveRoomId; const cachedStyle = this.platformCustomStyles && this.platformCustomStyles[routeId]; const addPlatformBillboard = (initialColor, initialSize) => { @@ -2312,121 +2326,152 @@ export default { const nextPos = i + 1 < waypoints.length ? originalPositions[i + 1] : null; if (this.isHoldWaypoint(wp)) { const params = this.parseHoldParams(wp); - const radius = params && params.radius != null ? params.radius : 500; - const semiMajor = params && (params.semiMajor != null || params.semiMajorAxis != null) ? (params.semiMajor ?? params.semiMajorAxis) : 500; - const semiMinor = params && (params.semiMinor != null || params.semiMinorAxis != null) ? (params.semiMinor ?? params.semiMinorAxis) : 300; - const headingRad = ((params && params.headingDeg != null ? params.headingDeg : 0) * Math.PI) / 180; + const legIndexHold = i - 1; + const defaultRadius = Math.max(this.MIN_HOLD_RADIUS_M, params && params.radius != null ? params.radius : 500); + const radius = Math.max(this.MIN_HOLD_RADIUS_M, (this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId] && this._routeHoldRadiiByRoute[routeId][legIndexHold] != null) + ? this._routeHoldRadiiByRoute[routeId][legIndexHold] + : defaultRadius); + const useCircle = (params && params.radius != null) || (this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId] && this._routeHoldRadiiByRoute[routeId][legIndexHold] != null); + const defaultSemiMajor = Math.max(this.MIN_ELLIPSE_SEMI_MAJOR_M, params && (params.semiMajor != null || params.semiMajorAxis != null) ? (params.semiMajor ?? params.semiMajorAxis) : 500); + const defaultSemiMinor = Math.max(this.MIN_ELLIPSE_SEMI_MINOR_M, params && (params.semiMinor != null || params.semiMinorAxis != null) ? (params.semiMinor ?? params.semiMinorAxis) : 300); + const defaultHeadingRad = ((params && params.headingDeg != null ? params.headingDeg : 0) * Math.PI) / 180; const clockwise = params && params.clockwise !== false; - const entry = params && params.radius != null - ? this.getCircleEntryPoint(currPos, lastPos, radius) - : this.getEllipseEntryPoint(currPos, lastPos, semiMajor, semiMinor, headingRad); - const exit = params && params.radius != null - ? this.getCircleTangentExitPoint(currPos, nextPos || currPos, radius, clockwise) - : this.getEllipseTangentExitPoint(currPos, nextPos || currPos, semiMajor, semiMinor, headingRad, clockwise); - let fullCirclePoints; - let arcPoints; - if (params && params.radius != null) { - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(currPos); - const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); - const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); - const toEntry = Cesium.Cartesian3.subtract(entry, currPos, new Cesium.Cartesian3()); - const entryAngle = Math.atan2(Cesium.Cartesian3.dot(toEntry, east), Cesium.Cartesian3.dot(toEntry, north)); - fullCirclePoints = this.getCircleFullCircle(currPos, radius, entryAngle, clockwise, 48); - arcPoints = this.getCircleArcEntryToExit(currPos, radius, entry, exit, clockwise, 48); - } else { - const enuE = Cesium.Transforms.eastNorthUpToFixedFrame(currPos); - const eastE = Cesium.Matrix4.getColumn(enuE, 0, new Cesium.Cartesian3()); - const northE = Cesium.Matrix4.getColumn(enuE, 1, new Cesium.Cartesian3()); - const toEntryE = Cesium.Cartesian3.subtract(entry, currPos, new Cesium.Cartesian3()); - const thetaE = Math.atan2(Cesium.Cartesian3.dot(toEntryE, eastE), Cesium.Cartesian3.dot(toEntryE, northE)); - const entryLocalAngle = thetaE - headingRad; - fullCirclePoints = this.getEllipseFullCircle(currPos, semiMajor, semiMinor, headingRad, entryLocalAngle, clockwise, 48); - arcPoints = this.getEllipseArcEntryToExit(currPos, semiMajor, semiMinor, headingRad, entry, exit, clockwise, 48); - } - // 整圈 + entry→exit 弧段,避免弦线:entry → 整圈(回到entry) → 弧段到exit - const holdPositions = [entry, ...fullCirclePoints.slice(1), ...arcPoints.slice(1)]; + const currPosCloned = Cesium.Cartesian3.clone(currPos); + const lastPosCloned = Cesium.Cartesian3.clone(lastPos); + const nextPosCloned = nextPos ? Cesium.Cartesian3.clone(nextPos) : null; + const routeIdHold = routeId; + const that = this; + const buildHoldPositions = (radiusOrEllipse) => { + const isCircleArg = typeof radiusOrEllipse === 'number'; + const R = isCircleArg ? radiusOrEllipse : 0; + const smj = isCircleArg ? defaultSemiMajor : (radiusOrEllipse.semiMajor ?? defaultSemiMajor); + const smn = isCircleArg ? defaultSemiMinor : (radiusOrEllipse.semiMinor ?? defaultSemiMinor); + const hd = isCircleArg ? defaultHeadingRad : ((radiusOrEllipse.headingDeg != null ? radiusOrEllipse.headingDeg * Math.PI / 180 : defaultHeadingRad)); + const entry = useCircle + ? that.getCircleTangentEntryPoint(currPosCloned, lastPosCloned, R, clockwise) + : that.getEllipseTangentEntryPoint(currPosCloned, lastPosCloned, smj, smn, hd, clockwise); + const exit = useCircle + ? that.getCircleTangentExitPoint(currPosCloned, nextPosCloned || currPosCloned, R, clockwise) + : that.getEllipseTangentExitPoint(currPosCloned, nextPosCloned || currPosCloned, smj, smn, hd, clockwise); + let fullCirclePoints; + let arcPoints; + if (useCircle) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(currPosCloned); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toEntry = Cesium.Cartesian3.subtract(entry, currPosCloned, new Cesium.Cartesian3()); + const entryAngle = Math.atan2(Cesium.Cartesian3.dot(toEntry, east), Cesium.Cartesian3.dot(toEntry, north)); + fullCirclePoints = that.getCircleFullCircle(currPosCloned, R, entryAngle, clockwise, 48); + arcPoints = that.getCircleArcEntryToExit(currPosCloned, R, entry, exit, clockwise, 48); + } else { + const tEntry = that.cartesianToEllipseParam(currPosCloned, smj, smn, hd, entry); + const entryLocalAngle = Math.atan2(smn * Math.sin(tEntry), smj * Math.cos(tEntry)); + fullCirclePoints = that.getEllipseFullCircle(currPosCloned, smj, smn, hd, entryLocalAngle, clockwise, 64); + arcPoints = that.buildEllipseHoldArc(currPosCloned, smj, smn, hd, entry, exit, clockwise, 80); + } + return [entry, ...(fullCirclePoints || []).slice(1), ...(arcPoints || []).slice(1)]; + }; + const holdPositions = useCircle ? buildHoldPositions(radius) : buildHoldPositions({ semiMajor: defaultSemiMajor, semiMinor: defaultSemiMinor, headingDeg: params && params.headingDeg != null ? params.headingDeg : 0 }); for (let k = 0; k < holdPositions.length; k++) finalPathPositions.push(holdPositions[k]); - // 盘旋不单独着色:仅作为主航线取点数据源,show:false 由主航线折线用 lineWidth/lineMaterial 统一绘制 + const getHoldPositions = () => { + if (useCircle) { + const R = Math.max(that.MIN_HOLD_RADIUS_M, (that._routeHoldRadiiByRoute && that._routeHoldRadiiByRoute[routeIdHold] && that._routeHoldRadiiByRoute[routeIdHold][legIndexHold] != null) + ? that._routeHoldRadiiByRoute[routeIdHold][legIndexHold] + : defaultRadius); + return buildHoldPositions(R); + } + const oe = that._routeHoldEllipseParamsByRoute && that._routeHoldEllipseParamsByRoute[routeIdHold] && that._routeHoldEllipseParamsByRoute[routeIdHold][legIndexHold]; + const smj = oe && oe.semiMajor != null ? Math.max(that.MIN_ELLIPSE_SEMI_MAJOR_M, oe.semiMajor) : defaultSemiMajor; + const smn = oe && oe.semiMinor != null ? Math.max(that.MIN_ELLIPSE_SEMI_MINOR_M, oe.semiMinor) : defaultSemiMinor; + return buildHoldPositions(oe ? { ...oe, semiMajor: smj, semiMinor: smn } : { semiMajor: smj, semiMinor: smn, headingDeg: (params && params.headingDeg != null ? params.headingDeg : 0) }); + }; this.viewer.entities.add({ id: `hold-line-${routeId}-${i}`, show: false, - polyline: { positions: holdPositions, width: lineWidth, material: lineMaterial, arcType: Cesium.ArcType.NONE, zIndex: 20 }, + polyline: { + positions: new Cesium.CallbackProperty(getHoldPositions, false), + width: lineWidth, + material: lineMaterial, + arcType: Cesium.ArcType.NONE, + zIndex: 20 + }, properties: { routeId: routeId } }); - lastPos = exit; + lastPos = holdPositions[holdPositions.length - 1]; } else { const effectiveAngle = this.getEffectiveTurnAngle(wp, i, waypoints.length); const radius = this.getWaypointRadius({ ...wp, turnAngle: effectiveAngle }); let nextLogical = nextPos; if (nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1])) { const holdParams = this.parseHoldParams(waypoints[i + 1]); - nextLogical = holdParams && holdParams.radius != null - ? this.getCircleEntryPoint(originalPositions[i + 1], currPos, holdParams.radius) - : this.getEllipseEntryPoint(originalPositions[i + 1], currPos, holdParams.semiMajor ?? 500, holdParams.semiMinor ?? 300, ((holdParams.headingDeg || 0) * Math.PI) / 180); + const holdRadius = Math.max(this.MIN_HOLD_RADIUS_M, (this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId] && this._routeHoldRadiiByRoute[routeId][i] != null) + ? this._routeHoldRadiiByRoute[routeId][i] + : (holdParams && holdParams.radius != null ? holdParams.radius : 500)); + const holdClock = holdParams && holdParams.clockwise !== false; + const smj = Math.max(this.MIN_ELLIPSE_SEMI_MAJOR_M, holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500); + const smn = Math.max(this.MIN_ELLIPSE_SEMI_MINOR_M, holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300); + nextLogical = holdParams && (holdParams.radius != null || (this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId] && this._routeHoldRadiiByRoute[routeId][i] != null)) + ? this.getCircleTangentEntryPoint(originalPositions[i + 1], lastPos, holdRadius, holdClock) + : this.getEllipseTangentEntryPoint(originalPositions[i + 1], lastPos, smj, smn, ((holdParams.headingDeg || 0) * Math.PI) / 180, holdClock); } - if (i < waypoints.length - 1 && radius > 0 && nextLogical) { + if (i < waypoints.length - 1 && nextLogical) { const lastPosCloned = Cesium.Cartesian3.clone(lastPos); - const currPosCloned = Cesium.Cartesian3.clone(currPos); const nextLogicalCloned = Cesium.Cartesian3.clone(nextLogical); - const routeIdCloned = routeId; - const dbIdCloned = wp.id; - const that = this; - const getArcPoints = () => { - const center = (that.waypointDragPreview && that.waypointDragPreview.routeId === routeIdCloned && that.waypointDragPreview.dbId === dbIdCloned) - ? that.waypointDragPreview.position : currPosCloned; - return that.computeArcPositions(lastPosCloned, center, nextLogicalCloned, radius); - }; - // 弧线用 CallbackProperty,拖拽虚拟点时实时重算实现动态预览 - this.viewer.entities.add({ - id: `arc-line-${routeId}-${i}`, - show: false, - polyline: { - positions: new Cesium.CallbackProperty(getArcPoints, false), - width: lineWidth, - material: lineMaterial, - arcType: Cesium.ArcType.NONE, - zIndex: 20 - }, - properties: { routeId: routeId } - }); - // 转弯半径两侧虚拟点也用 CallbackProperty,拖拽时随弧线实时更新 - const wpName = wp.name || `WP${i + 1}`; - [0, 1].forEach((idx) => { - const suffix = idx === 0 ? '_entry' : '_exit'; - const getPos = () => { - const pts = getArcPoints(); - return idx === 0 ? pts[0] : pts[pts.length - 1]; - }; + const nextIsHold = nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1]); + if (nextIsHold) { + // 下一航点是盘旋:只画 lastPos→切线入口,不画转弯弧,避免椭圆内弦、切入顺滑 + finalPathPositions.push(nextLogicalCloned); + lastPos = nextLogicalCloned; this.viewer.entities.add({ - id: `wp_${routeId}_${wp.id}${suffix}`, - name: wpName, - position: new Cesium.CallbackProperty(getPos, false), - properties: { - isMissionWaypoint: true, - routeId: routeId, - dbId: wp.id, + id: `arc-line-${routeId}-${i}`, + show: false, + polyline: { + positions: new Cesium.CallbackProperty(() => [Cesium.Cartesian3.clone(lastPosCloned), Cesium.Cartesian3.clone(nextLogicalCloned)], false), + width: lineWidth, + material: lineMaterial, + arcType: Cesium.ArcType.NONE, + zIndex: 20 }, - point: { - pixelSize: pixelSize, - color: Cesium.Color.fromCssColorString(wpColor), - outlineColor: Cesium.Color.fromCssColorString(wpOutline), - outlineWidth: wpOutlineW, - disableDepthTestDistance: Number.POSITIVE_INFINITY + properties: { routeId: routeId } + }); + } else if (radius > 0) { + const currPosCloned = Cesium.Cartesian3.clone(currPos); + const routeIdCloned = routeId; + const dbIdCloned = wp.id; + const that = this; + const getArcPoints = () => that.computeArcPositions(lastPosCloned, currPosCloned, nextLogicalCloned, radius); + this.viewer.entities.add({ + id: `arc-line-${routeId}-${i}`, + show: false, + polyline: { + positions: new Cesium.CallbackProperty(getArcPoints, false), + width: lineWidth, + material: lineMaterial, + arcType: Cesium.ArcType.NONE, + zIndex: 20 }, - 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, pixelSize + 8)), - fillColor: Cesium.Color.fromCssColorString(wp.labelColor || '#2c2c2c'), - outlineColor: Cesium.Color.fromCssColorString('#e8e8e8'), - outlineWidth: 0.5, - style: Cesium.LabelStyle.FILL_AND_OUTLINE - } + properties: { routeId: routeId } }); - }); - const arcPoints = getArcPoints(); - finalPathPositions.push(...arcPoints); - lastPos = arcPoints[arcPoints.length - 1]; + const wpName = wp.name || `WP${i + 1}`; + [0, 1].forEach((idx) => { + const suffix = idx === 0 ? '_entry' : '_exit'; + const getPos = () => { const pts = getArcPoints(); return idx === 0 ? pts[0] : pts[pts.length - 1]; }; + this.viewer.entities.add({ + id: `wp_${routeId}_${wp.id}${suffix}`, + name: wpName, + position: new Cesium.CallbackProperty(getPos, false), + properties: { isMissionWaypoint: true, routeId: routeId, dbId: wp.id }, + point: { pixelSize: pixelSize, color: Cesium.Color.fromCssColorString(wpColor), outlineColor: Cesium.Color.fromCssColorString(wpOutline), 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, pixelSize + 8)), fillColor: Cesium.Color.fromCssColorString(wp.labelColor || '#2c2c2c'), outlineColor: Cesium.Color.fromCssColorString('#e8e8e8'), outlineWidth: 0.5, style: Cesium.LabelStyle.FILL_AND_OUTLINE } + }); + }); + const arcPoints = getArcPoints(); + finalPathPositions.push(...arcPoints); + lastPos = arcPoints[arcPoints.length - 1]; + } else { + finalPathPositions.push(currPos); + lastPos = currPos; + } } else { finalPathPositions.push(currPos); lastPos = currPos; @@ -2575,62 +2620,98 @@ export default { return points; }, - /** 椭圆上从 entry 到 exit 的弧段(按顺时针/逆时针) */ - getEllipseArcEntryToExit(centerCartesian, semiMajorM, semiMinorM, headingRad, entryCartesian, exitCartesian, clockwise, numPoints) { + /** 椭圆参数 t 对应点(世界坐标)。约定:长轴方位 headingRad 从北起算,localX=a*cos(t) 沿长轴,north = localX*cos(h)-localY*sin(h), east = localX*sin(h)+localY*cos(h) */ + ellipsePointAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, t) { const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); - const toLocalAngle = (cart) => { - const toP = Cesium.Cartesian3.subtract(cart, centerCartesian, new Cesium.Cartesian3()); - const e = Cesium.Cartesian3.dot(toP, east); - const n = Cesium.Cartesian3.dot(toP, north); - const theta = Math.atan2(e, n); - const local = theta - headingRad; - return local; - }; - let entryT = toLocalAngle(entryCartesian); - let exitT = toLocalAngle(exitCartesian); - let diff = exitT - entryT; - const sign = clockwise ? -1 : 1; - if (sign * diff <= 0) diff += sign * 2 * Math.PI; const c = Math.cos(headingRad); const s = Math.sin(headingRad); - const points = []; - for (let i = 0; i <= numPoints; i++) { - const t = i / numPoints; - const angle = entryT + sign * t * Math.abs(diff); - const localE = semiMajorM * Math.cos(angle) * c - semiMinorM * Math.sin(angle) * s; - const localN = semiMajorM * Math.cos(angle) * s + semiMinorM * Math.sin(angle) * c; - const offset = Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, localN, new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, localE, new Cesium.Cartesian3()), - new Cesium.Cartesian3() - ); - points.push(Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3())); - } - return points; + const lx = semiMajorM * Math.cos(t); + const ly = semiMinorM * Math.sin(t); + const northOffset = lx * c - ly * s; + const eastOffset = lx * s + ly * c; + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, northOffset, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, eastOffset, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); }, - /** 椭圆上整圈(360°)采样,从 startLocalAngle 起按顺时针/逆时针,用于盘旋段渲染为整椭圆 */ - getEllipseFullCircle(centerCartesian, semiMajorM, semiMinorM, headingRad, startLocalAngle, clockwise, numPoints) { + /** 世界坐标点(在椭圆上或附近)转椭圆参数 t ∈ [0, 2π) */ + cartesianToEllipseParam(centerCartesian, semiMajorM, semiMinorM, headingRad, cartesian) { const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); - const sign = clockwise ? -1 : 1; + const toP = Cesium.Cartesian3.subtract(cartesian, centerCartesian, new Cesium.Cartesian3()); + const e = Cesium.Cartesian3.dot(toP, east); + const n = Cesium.Cartesian3.dot(toP, north); const c = Math.cos(headingRad); const s = Math.sin(headingRad); + const localX = n * c + e * s; + const localY = -n * s + e * c; + let t = Math.atan2(localY / semiMinorM, localX / semiMajorM); + if (t < 0) t += 2 * Math.PI; + return t; + }, + + /** + * 椭圆盘旋轨迹:仅一段弧,从切线入口沿椭圆到切线出口(长弧,绕一圈),无弦、无整圈+短弧。 + * 返回 Cartesian3[],首点为 entry,末点为 exit,中间点均匀分布在椭圆上。 + */ + buildEllipseHoldArc(centerCartesian, semiMajorM, semiMinorM, headingRad, entryCartesian, exitCartesian, clockwise, numPoints) { + if (semiMajorM <= 0 || semiMinorM <= 0) return [Cesium.Cartesian3.clone(entryCartesian), Cesium.Cartesian3.clone(exitCartesian)]; + const tEntry = this.cartesianToEllipseParam(centerCartesian, semiMajorM, semiMinorM, headingRad, entryCartesian); + const tExit = this.cartesianToEllipseParam(centerCartesian, semiMajorM, semiMinorM, headingRad, exitCartesian); + const d = (tExit - tEntry + 2 * Math.PI) % (2 * Math.PI); + const sign = clockwise ? -1 : 1; + const longSpan = clockwise ? (2 * Math.PI - d) : (d > Math.PI ? d : 2 * Math.PI - d); + const n = Math.max(2, numPoints || 80); const points = []; - for (let i = 0; i <= numPoints; i++) { - const t = i / numPoints; - const angle = startLocalAngle + sign * t * 2 * Math.PI; - const localE = semiMajorM * Math.cos(angle) * c - semiMinorM * Math.sin(angle) * s; - const localN = semiMajorM * Math.cos(angle) * s + semiMinorM * Math.sin(angle) * c; - const offset = Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, localN, new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, localE, new Cesium.Cartesian3()), - new Cesium.Cartesian3() - ); - points.push(Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3())); + for (let i = 0; i <= n; i++) { + let t = tEntry + sign * longSpan * (i / n); + t = ((t % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + points.push(this.ellipsePointAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, t)); + } + points[0] = Cesium.Cartesian3.clone(entryCartesian); + points[points.length - 1] = Cesium.Cartesian3.clone(exitCartesian); + return points; + }, + + /** 将极角(相对长轴)转为椭圆参数 t(弧长/弧长计算用) */ + polarToEllipseParam(polarRad, semiMajorM, semiMinorM) { + return Math.atan2(semiMajorM * Math.sin(polarRad), semiMinorM * Math.cos(polarRad)); + }, + + /** 椭圆上从 entry 到 exit 的弧段。useLongArc=true 时走长弧(盘旋只画一段弧);false 时走短弧(用于弧长计算等) */ + getEllipseArcEntryToExit(centerCartesian, semiMajorM, semiMinorM, headingRad, entryCartesian, exitCartesian, clockwise, numPoints, useLongArc) { + if (useLongArc) { + return this.buildEllipseHoldArc(centerCartesian, semiMajorM, semiMinorM, headingRad, entryCartesian, exitCartesian, clockwise, numPoints || 80); + } + const tEntry = this.cartesianToEllipseParam(centerCartesian, semiMajorM, semiMinorM, headingRad, entryCartesian); + let tExit = this.cartesianToEllipseParam(centerCartesian, semiMajorM, semiMinorM, headingRad, exitCartesian); + let diff = (tExit - tEntry + 2 * Math.PI) % (2 * Math.PI); + const sign = clockwise ? -1 : 1; + if (sign * (diff - Math.PI) > 0) diff -= 2 * Math.PI; + const n = Math.max(2, numPoints || 48); + const points = []; + for (let i = 0; i <= n; i++) { + const t = tEntry + sign * diff * (i / n); + points.push(this.ellipsePointAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, ((t % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI))); + } + return points; + }, + + /** 椭圆整圈(用于弧长计算等),从 startLocalAngle 起按顺时针/逆时针 */ + getEllipseFullCircle(centerCartesian, semiMajorM, semiMinorM, headingRad, startLocalAngle, clockwise, numPoints) { + const t0 = this.polarToEllipseParam(startLocalAngle, semiMajorM, semiMinorM); + const sign = clockwise ? -1 : 1; + const n = Math.max(2, numPoints || 64); + const points = []; + for (let i = 0; i <= n; i++) { + const t = t0 + sign * (2 * Math.PI) * (i / n); + points.push(this.ellipsePointAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, ((t % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI))); } return points; }, @@ -2684,32 +2765,20 @@ export default { return points; }, - /** 椭圆:中心、半长轴/半短轴(米)、长轴方位(弧度)、顺时针、采样数 → 世界坐标点数组 */ + /** 椭圆:中心、半长轴/半短轴(米)、长轴方位(弧度)、顺时针、采样数 → 世界坐标点数组(与 ellipsePointAtParam 同一约定) */ computeEllipsePositions(centerCartesian, semiMajorM, semiMinorM, headingRad, clockwise, numPoints) { if (!this.viewer || !centerCartesian || semiMajorM <= 0 || semiMinorM <= 0) return []; - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); - const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); - const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); - const c = Math.cos(headingRad); - const s = Math.sin(headingRad); - const points = []; const sign = clockwise ? -1 : 1; + const points = []; for (let i = 0; i <= numPoints; i++) { - const t = i / numPoints; - const angle = sign * t * 2 * Math.PI; - const localE = semiMajorM * Math.cos(angle) * c - semiMinorM * Math.sin(angle) * s; - const localN = semiMajorM * Math.cos(angle) * s + semiMinorM * Math.sin(angle) * c; - const offset = Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, localN, new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, localE, new Cesium.Cartesian3()), - new Cesium.Cartesian3() - ); - points.push(Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3())); + const t = sign * (i / numPoints) * 2 * Math.PI; + const tNorm = ((t % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + points.push(this.ellipsePointAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, tNorm)); } return points; }, - /** 圆上进入点:从 prev 飞向 center 时与圆的交点(靠近 prev 的那侧) */ + /** 圆上进入点:从 prev 飞向 center 时与圆的交点(靠近 prev 的那侧),径向进入,仅作兼容保留 */ getCircleEntryPoint(centerCartesian, prevPointCartesian, radiusMeters) { const toCenter = Cesium.Cartesian3.subtract(centerCartesian, prevPointCartesian, new Cesium.Cartesian3()); const dist = Cesium.Cartesian3.magnitude(toCenter); @@ -2719,6 +2788,153 @@ export default { return Cesium.Cartesian3.add(prevPointCartesian, Cesium.Cartesian3.multiplyByScalar(unit, dist - radiusMeters, new Cesium.Cartesian3()), new Cesium.Cartesian3()); }, + /** 圆上切线进入点:从 prev 飞向圆时在圆上的切点(与出口切点对称,选使进入后沿顺时针/逆时针顺滑的那一侧) */ + getCircleTangentEntryPoint(centerCartesian, prevPointCartesian, radiusMeters, clockwise) { + const toPrev = Cesium.Cartesian3.subtract(prevPointCartesian, centerCartesian, new Cesium.Cartesian3()); + const d = Cesium.Cartesian3.magnitude(toPrev); + if (d < 1e-6) return centerCartesian; + if (radiusMeters >= d) return Cesium.Cartesian3.clone(prevPointCartesian); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const e = Cesium.Cartesian3.dot(toPrev, east); + const n = Cesium.Cartesian3.dot(toPrev, north); + const theta = Math.atan2(e, n); + const alpha = Math.acos(Math.min(1, radiusMeters / d)); + const sign = clockwise ? 1 : -1; + const entryAngle = theta - sign * alpha; + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(entryAngle) * radiusMeters, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(entryAngle) * radiusMeters, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); + }, + + /** + * 根据盘旋总距离反算圆半径,使从进入点到切点出口的弧长 = totalHoldDistM(整圈 + entry→exit 弧), + * 从而在 k+10 时刻飞机自然落在切点,无需强制位移。返回值不小于 MIN_HOLD_RADIUS_M。 + * @param centerCartesian - 盘旋中心 + * @param prevPointCartesian - 上一航点 + * @param nextPointCartesian - 下一航点 + * @param clockwise - 是否顺时针 + * @param totalHoldDistM - 盘旋段总飞行距离(米) + * @returns 半径(米),若无解返回 null + */ + computeHoldRadiusForDuration(centerCartesian, prevPointCartesian, nextPointCartesian, clockwise, totalHoldDistM) { + if (!totalHoldDistM || totalHoldDistM <= 0) return null; + const minR = this.MIN_HOLD_RADIUS_M; + const dToNext = Cesium.Cartesian3.distance(centerCartesian, nextPointCartesian); + const dFromPrev = Cesium.Cartesian3.distance(centerCartesian, prevPointCartesian); + if (dToNext < 1e-6) return null; + const Rmax = Math.min(dToNext * 0.999, Math.max(1, dFromPrev - 1)); + if (Rmax < minR) return minR; + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toPrev = Cesium.Cartesian3.subtract(prevPointCartesian, centerCartesian, new Cesium.Cartesian3()); + const thetaPrev = Math.atan2(Cesium.Cartesian3.dot(toPrev, east), Cesium.Cartesian3.dot(toPrev, north)); + const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, centerCartesian, new Cesium.Cartesian3()); + const theta = Math.atan2(Cesium.Cartesian3.dot(toNext, east), Cesium.Cartesian3.dot(toNext, north)); + const sign = clockwise ? 1 : -1; + const arcAngleForR = (R) => { + if (R >= dToNext || R >= dFromPrev) return NaN; + const alphaNext = Math.acos(Math.min(1, R / dToNext)); + const alphaPrev = Math.acos(Math.min(1, R / dFromPrev)); + const entryAngle = thetaPrev - sign * alphaPrev; + const exitAngle = theta + sign * alphaNext; + const rawDiff = exitAngle - entryAngle; + const arcAngle = (sign === 1) + ? (rawDiff <= 0 ? -rawDiff : 2 * Math.PI - rawDiff) + : (rawDiff >= 0 ? rawDiff : 2 * Math.PI + rawDiff); + return arcAngle; + }; + const fullCirclePlusArcForR = (R) => { + const arc = arcAngleForR(R); + if (!Number.isFinite(arc)) return NaN; + return 2 * Math.PI + arc; + }; + let R = Math.min(500, Rmax * 0.5); + R = Math.max(minR, R); + for (let iter = 0; iter < 50; iter++) { + const totalAngle = fullCirclePlusArcForR(R); + if (!Number.isFinite(totalAngle) || totalAngle < 1e-6) return minR; + const Rnew = totalHoldDistM / totalAngle; + if (Math.abs(Rnew - R) < 0.5) return Math.max(minR, Rnew); + R = Math.max(minR, Math.min(Rmax, Rnew)); + } + return Math.max(minR, R); + }, + + /** + * 设置某条航线的盘旋计算半径(由推演侧根据 k+10 落点反算),使地图上的盘旋轨迹与飞机实际飞行弧线一致并顺滑进入切点。 + * @param routeId - 航线 id + * @param holdRadiusByLegIndex - { [legIndex]: number } 各盘旋段半径(米) + */ + setRouteHoldRadii(routeId, holdRadiusByLegIndex) { + if (!this._routeHoldRadiiByRoute) this._routeHoldRadiiByRoute = {}; + this._routeHoldRadiiByRoute[routeId] = holdRadiusByLegIndex && typeof holdRadiusByLegIndex === 'object' ? { ...holdRadiusByLegIndex } : {}; + if (this.viewer && this.viewer.scene) this.viewer.scene.requestRender(); + }, + + /** 设置某条航线的椭圆盘旋计算参数(由推演侧反算),使椭圆满足 k+10 自然落点。 */ + setRouteHoldEllipseParams(routeId, holdEllipseParamsByLegIndex) { + if (!this._routeHoldEllipseParamsByRoute) this._routeHoldEllipseParamsByRoute = {}; + this._routeHoldEllipseParamsByRoute[routeId] = holdEllipseParamsByLegIndex && typeof holdEllipseParamsByLegIndex === 'object' ? { ...holdEllipseParamsByLegIndex } : {}; + if (this.viewer && this.viewer.scene) this.viewer.scene.requestRender(); + }, + + /** 椭圆盘旋总弧长(切线入口 → 整椭圆 → 弧至切线出口),用于反算尺寸 */ + ellipseHoldArcLengthM(centerCartesian, prevPointCartesian, nextPointCartesian, semiMajorM, semiMinorM, headingRad, clockwise) { + const entry = this.getEllipseTangentEntryPoint(centerCartesian, prevPointCartesian, semiMajorM, semiMinorM, headingRad, clockwise); + const exit = this.getEllipseTangentExitPoint(centerCartesian, nextPointCartesian || centerCartesian, semiMajorM, semiMinorM, headingRad, clockwise); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toEntry = Cesium.Cartesian3.subtract(entry, centerCartesian, new Cesium.Cartesian3()); + const e0 = Cesium.Cartesian3.dot(toEntry, east); + const n0 = Cesium.Cartesian3.dot(toEntry, north); + const theta0 = Math.atan2(e0, n0); + const entryLocalAngle = theta0 - headingRad; + const fullCircle = this.getEllipseFullCircle(centerCartesian, semiMajorM, semiMinorM, headingRad, entryLocalAngle, clockwise, 64); + const arcToExit = this.getEllipseArcEntryToExit(centerCartesian, semiMajorM, semiMinorM, headingRad, entry, exit, clockwise, 48); + const pts = [entry, ...fullCircle.slice(1), ...arcToExit.slice(1)]; + let len = 0; + for (let i = 1; i < pts.length; i++) len += Cesium.Cartesian3.distance(pts[i - 1], pts[i]); + return len; + }, + + /** + * 根据盘旋总距离反算椭圆半轴,使切线入口→整椭圆→切线出口的弧长 = totalHoldDistM(椭圆保持长轴方位与长短轴比)。 + * 返回值不小于 MIN_ELLIPSE_SEMI_MAJOR_M / MIN_ELLIPSE_SEMI_MINOR_M,保证飞机能盘旋开。 + * @param headingDeg - 长轴方位(度) + * @param aspectMajor - 长半轴参考(用于比例),米 + * @param aspectMinor - 短半轴参考(用于比例),米 + * @returns { semiMajor, semiMinor } 或 null + */ + computeEllipseParamsForDuration(centerCartesian, prevPointCartesian, nextPointCartesian, clockwise, totalHoldDistM, headingDeg, aspectMajor, aspectMinor) { + if (!totalHoldDistM || totalHoldDistM <= 0) return null; + const minA = this.MIN_ELLIPSE_SEMI_MAJOR_M; + const minB = this.MIN_ELLIPSE_SEMI_MINOR_M; + const a0 = Math.max(50, aspectMajor || 500); + const b0 = Math.max(30, aspectMinor || 300); + const headingRad = ((headingDeg != null ? headingDeg : 0) * Math.PI) / 180; + const next = nextPointCartesian || centerCartesian; + let kLo = 0.1; + let kHi = 20; + for (let iter = 0; iter < 40; iter++) { + const k = (kLo + kHi) / 2; + const smj = k * a0; + const smn = k * b0; + const len = this.ellipseHoldArcLengthM(centerCartesian, prevPointCartesian, next, smj, smn, headingRad, clockwise); + if (!Number.isFinite(len) || len <= 0) return { semiMajor: minA, semiMinor: minB }; + if (Math.abs(len - totalHoldDistM) < 2) return { semiMajor: Math.max(minA, smj), semiMinor: Math.max(minB, smn) }; + if (len < totalHoldDistM) kLo = k; else kHi = k; + } + const k = (kLo + kHi) / 2; + return { semiMajor: Math.max(minA, k * a0), semiMinor: Math.max(minB, k * b0) }; + }, + /** 圆上切线出口点:从圆飞往 next 时在圆上的切点(选顺时针/逆时针中朝向 next 的那一侧) */ getCircleTangentExitPoint(centerCartesian, nextPointCartesian, radiusMeters, clockwise) { const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, centerCartesian, new Cesium.Cartesian3()); @@ -2741,7 +2957,72 @@ export default { return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); }, - /** 椭圆上进入点:从 prev 指向 center 的方向与椭圆边的交点(靠近 prev 的一侧) */ + /** 椭圆在参数 t 处的单位切向量(世界坐标,t 增加方向);clockwise 为 true 时返回飞行方向(t 减少) */ + ellipseTangentAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, t, clockwise) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const c = Math.cos(headingRad); + const s = Math.sin(headingRad); + const dx = -semiMajorM * Math.sin(t); + const dy = semiMinorM * Math.cos(t); + const northComp = dx * c - dy * s; + const eastComp = dx * s + dy * c; + const len = Math.sqrt(northComp * northComp + eastComp * eastComp) || 1; + const sign = clockwise ? -1 : 1; + return Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, (sign * northComp) / len, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, (sign * eastComp) / len, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + }, + + /** 椭圆上切线进入点:选使直线 prev→entry 与椭圆在 entry 处相切且弧线离开方向与 approach 一致的切点(G1 连续) */ + getEllipseTangentEntryPoint(centerCartesian, prevPointCartesian, semiMajorM, semiMinorM, headingRad, clockwise) { + const toPrev = Cesium.Cartesian3.subtract(prevPointCartesian, centerCartesian, new Cesium.Cartesian3()); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const de = Cesium.Cartesian3.dot(toPrev, east); + const dn = Cesium.Cartesian3.dot(toPrev, north); + const c = Math.cos(headingRad); + const s = Math.sin(headingRad); + const px = dn * c + de * s; + const py = -dn * s + de * c; + const a = semiMajorM; + const b = semiMinorM; + const A = px / a; + const B = py / b; + const r2 = A * A + B * B; + if (r2 <= 1) return this.getEllipseEntryPoint(centerCartesian, prevPointCartesian, semiMajorM, semiMinorM, headingRad); + const eta = Math.atan2(B, A); + const ac = Math.acos(Math.min(1, 1 / Math.sqrt(r2))); + const t1 = eta - ac; + const t2 = eta + ac; + const toCart = (t) => { + const lx = a * Math.cos(t); + const ly = b * Math.sin(t); + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, lx * c - ly * s, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, lx * s + ly * c, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); + }; + const p1 = toCart(t1); + const p2 = toCart(t2); + const approach1 = Cesium.Cartesian3.subtract(p1, prevPointCartesian, new Cesium.Cartesian3()); + const approach2 = Cesium.Cartesian3.subtract(p2, prevPointCartesian, new Cesium.Cartesian3()); + Cesium.Cartesian3.normalize(approach1, approach1); + Cesium.Cartesian3.normalize(approach2, approach2); + const tangent1 = this.ellipseTangentAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, t1, clockwise); + const tangent2 = this.ellipseTangentAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, t2, clockwise); + const dot1 = Cesium.Cartesian3.dot(approach1, tangent1); + const dot2 = Cesium.Cartesian3.dot(approach2, tangent2); + return dot1 >= dot2 ? p1 : p2; + }, + + /** 椭圆上进入点:从 prev 指向 center 的方向与椭圆边的交点(靠近 prev 的一侧),径向进入,仅作兼容保留 */ getEllipseEntryPoint(centerCartesian, prevPointCartesian, semiMajorM, semiMinorM, headingRad) { const toPrev = Cesium.Cartesian3.subtract(prevPointCartesian, centerCartesian, new Cesium.Cartesian3()); const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); @@ -2768,37 +3049,64 @@ export default { return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); }, - /** 椭圆上“出口点”:沿长轴方向近似为下一航点方向的切点(取椭圆上最靠近 next 的点再沿法向微调为切向出口) */ + /** 椭圆上切线出口点:从椭圆飞往 next 时在椭圆上的切点;选使弧线切向与 (next - exit) 同向的切点(G1 连续) */ getEllipseTangentExitPoint(centerCartesian, nextPointCartesian, semiMajorM, semiMinorM, headingRad, clockwise) { + const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, centerCartesian, new Cesium.Cartesian3()); const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); - const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, centerCartesian, new Cesium.Cartesian3()); - const e = Cesium.Cartesian3.dot(toNext, east); - const n = Cesium.Cartesian3.dot(toNext, north); - const theta = Math.atan2(e, n); - const localAngle = theta - headingRad; - const cosA = Math.cos(localAngle); - const sinA = Math.sin(localAngle); - const r = (semiMajorM * semiMinorM) / Math.sqrt((semiMinorM * cosA) ** 2 + (semiMajorM * sinA) ** 2); - const sign = clockwise ? -1 : 1; - const exitLocal = sign * Math.atan2(semiMinorM * sinA, semiMajorM * cosA); - const localE = semiMajorM * Math.cos(exitLocal) * Math.cos(headingRad) - semiMinorM * Math.sin(exitLocal) * Math.sin(headingRad); - const localN = semiMajorM * Math.cos(exitLocal) * Math.sin(headingRad) + semiMinorM * Math.sin(exitLocal) * Math.cos(headingRad); - const offset = Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, localN, new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, localE, new Cesium.Cartesian3()), - new Cesium.Cartesian3() - ); - return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); + const de = Cesium.Cartesian3.dot(toNext, east); + const dn = Cesium.Cartesian3.dot(toNext, north); + const c = Math.cos(headingRad); + const s = Math.sin(headingRad); + const px = dn * c + de * s; + const py = -dn * s + de * c; + const a = semiMajorM; + const b = semiMinorM; + const A = px / a; + const B = py / b; + const r2 = A * A + B * B; + if (r2 <= 1) { + const t = Math.atan2(py / b, px / a); + return this.ellipsePointAtParam(centerCartesian, a, b, headingRad, t); + } + const eta = Math.atan2(B, A); + const ac = Math.acos(Math.min(1, 1 / Math.sqrt(r2))); + const t1 = eta - ac; + const t2 = eta + ac; + const toCart = (t) => { + const lx = a * Math.cos(t); + const ly = b * Math.sin(t); + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, lx * c - ly * s, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, lx * s + ly * c, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); + }; + const p1 = toCart(t1); + const p2 = toCart(t2); + const exitDir1 = Cesium.Cartesian3.subtract(nextPointCartesian, p1, new Cesium.Cartesian3()); + const exitDir2 = Cesium.Cartesian3.subtract(nextPointCartesian, p2, new Cesium.Cartesian3()); + Cesium.Cartesian3.normalize(exitDir1, exitDir1); + Cesium.Cartesian3.normalize(exitDir2, exitDir2); + const tangent1 = this.ellipseTangentAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, t1, clockwise); + const tangent2 = this.ellipseTangentAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, t2, clockwise); + const dot1 = Cesium.Cartesian3.dot(exitDir1, tangent1); + const dot2 = Cesium.Cartesian3.dot(exitDir2, tangent2); + return dot1 >= dot2 ? p1 : p2; }, /** * 获取与地图绘制一致的带转弯弧与盘旋弧的路径(用于推演时图标沿弧线运动)。 + * @param waypoints - 航点列表 + * @param options - 可选 { holdRadiusByLegIndex: { [legIndex]: number } } 为指定盘旋段覆盖半径(使落点精准在切点) * @returns {{ path, segmentEndIndices, holdArcRanges: { [legIndex]: { start, end } } }} */ - getRoutePathWithSegmentIndices(waypoints) { + getRoutePathWithSegmentIndices(waypoints, options) { if (!waypoints || waypoints.length === 0) return { path: [], segmentEndIndices: [], holdArcRanges: {} }; + const holdRadiusByLegIndex = (options && options.holdRadiusByLegIndex) || {}; + const holdEllipseParamsByLegIndex = (options && options.holdEllipseParamsByLegIndex) || {}; const ellipsoid = this.viewer.scene.globe.ellipsoid; const toLngLatAlt = (cartesian) => { const carto = Cesium.Cartographic.fromCartesian(cartesian, ellipsoid); @@ -2821,21 +3129,27 @@ export default { const nextPos = i + 1 < waypoints.length ? originalPositions[i + 1] : null; if (this.isHoldWaypoint(wp)) { const params = this.parseHoldParams(wp); - const radius = params && params.radius != null ? params.radius : 500; - const semiMajor = params && (params.semiMajor != null || params.semiMajorAxis != null) ? (params.semiMajor ?? params.semiMajorAxis) : 500; - const semiMinor = params && (params.semiMinor != null || params.semiMinorAxis != null) ? (params.semiMinor ?? params.semiMinorAxis) : 300; - const headingRad = ((params && params.headingDeg != null ? params.headingDeg : 0) * Math.PI) / 180; + const legIndex = i - 1; + const overrideRadius = holdRadiusByLegIndex[legIndex]; + const radius = Math.max(this.MIN_HOLD_RADIUS_M, (overrideRadius != null && Number.isFinite(overrideRadius)) + ? overrideRadius + : (params && params.radius != null ? params.radius : 500)); + const useCircle = (overrideRadius != null && Number.isFinite(overrideRadius)) || (params && params.radius != null); + const overrideEllipse = holdEllipseParamsByLegIndex[legIndex]; + const semiMajor = Math.max(this.MIN_ELLIPSE_SEMI_MAJOR_M, (overrideEllipse && overrideEllipse.semiMajor != null) ? overrideEllipse.semiMajor : (params && (params.semiMajor != null || params.semiMajorAxis != null) ? (params.semiMajor ?? params.semiMajorAxis) : 500)); + const semiMinor = Math.max(this.MIN_ELLIPSE_SEMI_MINOR_M, (overrideEllipse && overrideEllipse.semiMinor != null) ? overrideEllipse.semiMinor : (params && (params.semiMinor != null || params.semiMinorAxis != null) ? (params.semiMinor ?? params.semiMinorAxis) : 300)); + const headingRad = ((overrideEllipse && overrideEllipse.headingDeg != null) ? overrideEllipse.headingDeg : (params && params.headingDeg != null ? params.headingDeg : 0)) * Math.PI / 180; const clockwise = params && params.clockwise !== false; - const entry = params && params.radius != null - ? this.getCircleEntryPoint(currPos, lastPos, radius) - : this.getEllipseEntryPoint(currPos, lastPos, semiMajor, semiMinor, headingRad); - const exit = params && params.radius != null + const entry = useCircle + ? this.getCircleTangentEntryPoint(currPos, lastPos, radius, clockwise) + : this.getEllipseTangentEntryPoint(currPos, lastPos, semiMajor, semiMinor, headingRad, clockwise); + const exit = useCircle ? this.getCircleTangentExitPoint(currPos, nextPos || currPos, radius, clockwise) : this.getEllipseTangentExitPoint(currPos, nextPos || currPos, semiMajor, semiMinor, headingRad, clockwise); const arcStartIdx = path.length; let fullCirclePoints; let arcPoints; - if (params && params.radius != null) { + if (useCircle) { const enu = Cesium.Transforms.eastNorthUpToFixedFrame(currPos); const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); @@ -2844,16 +3158,14 @@ export default { fullCirclePoints = this.getCircleFullCircle(currPos, radius, entryAngle, clockwise, 48); arcPoints = this.getCircleArcEntryToExit(currPos, radius, entry, exit, clockwise, 48); } else { - const enuE = Cesium.Transforms.eastNorthUpToFixedFrame(currPos); - const eastE = Cesium.Matrix4.getColumn(enuE, 0, new Cesium.Cartesian3()); - const northE = Cesium.Matrix4.getColumn(enuE, 1, new Cesium.Cartesian3()); - const toEntryE = Cesium.Cartesian3.subtract(entry, currPos, new Cesium.Cartesian3()); - const thetaE = Math.atan2(Cesium.Cartesian3.dot(toEntryE, eastE), Cesium.Cartesian3.dot(toEntryE, northE)); - const entryLocalAngle = thetaE - headingRad; - fullCirclePoints = this.getEllipseFullCircle(currPos, semiMajor, semiMinor, headingRad, entryLocalAngle, clockwise, 48); - arcPoints = this.getEllipseArcEntryToExit(currPos, semiMajor, semiMinor, headingRad, entry, exit, clockwise, 48); - } - const holdPositions = [entry, ...fullCirclePoints.slice(1), ...arcPoints.slice(1)]; + const tEntry = this.cartesianToEllipseParam(currPos, semiMajor, semiMinor, headingRad, entry); + const entryLocalAngle = Math.atan2(semiMinor * Math.sin(tEntry), semiMajor * Math.cos(tEntry)); + fullCirclePoints = this.getEllipseFullCircle(currPos, semiMajor, semiMinor, headingRad, entryLocalAngle, clockwise, 64); + arcPoints = this.buildEllipseHoldArc(currPos, semiMajor, semiMinor, headingRad, entry, exit, clockwise, 80); + } + const holdPositions = useCircle + ? [entry, ...fullCirclePoints.slice(1), ...arcPoints.slice(1)] + : [entry, ...fullCirclePoints.slice(1), ...arcPoints.slice(1)]; for (let k = 0; k < holdPositions.length; k++) path.push(toLngLatAlt(holdPositions[k])); holdArcRanges[i - 1] = { start: arcStartIdx, end: path.length - 1 }; segmentEndIndices[i - 1] = path.length - 1; @@ -2862,16 +3174,33 @@ export default { let nextLogical = nextPos; if (nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1])) { const holdParams = this.parseHoldParams(waypoints[i + 1]); - nextLogical = holdParams && holdParams.radius != null - ? this.getCircleEntryPoint(originalPositions[i + 1], currPos, holdParams.radius) - : this.getEllipseEntryPoint(originalPositions[i + 1], currPos, holdParams.semiMajor ?? 500, holdParams.semiMinor ?? 300, ((holdParams.headingDeg || 0) * Math.PI) / 180); + const holdClock = holdParams && holdParams.clockwise !== false; + if (holdParams && (holdParams.radius != null || (holdRadiusByLegIndex && holdRadiusByLegIndex[i] != null))) { + const holdR = Math.max(this.MIN_HOLD_RADIUS_M, (holdRadiusByLegIndex && holdRadiusByLegIndex[i] != null) ? holdRadiusByLegIndex[i] : (holdParams.radius ?? 500)); + nextLogical = this.getCircleTangentEntryPoint(originalPositions[i + 1], lastPos, holdR, holdClock); + } else if (holdParams) { + const he = holdEllipseParamsByLegIndex[i] || holdParams; + const smj = Math.max(this.MIN_ELLIPSE_SEMI_MAJOR_M, he.semiMajor ?? he.semiMajorAxis ?? 500); + const smn = Math.max(this.MIN_ELLIPSE_SEMI_MINOR_M, he.semiMinor ?? he.semiMinorAxis ?? 300); + const hd = ((he.headingDeg != null ? he.headingDeg : 0) * Math.PI) / 180; + nextLogical = this.getEllipseTangentEntryPoint(originalPositions[i + 1], lastPos, smj, smn, hd, holdClock); + } } const effectiveAngle = this.getEffectiveTurnAngle(wp, i, waypoints.length); const radius = this.getWaypointRadius({ ...wp, turnAngle: effectiveAngle }); - if (i < waypoints.length - 1 && radius > 0 && nextLogical) { - const arcPoints = this.computeArcPositions(lastPos, currPos, nextLogical, radius); - arcPoints.forEach(p => path.push(toLngLatAlt(p))); - lastPos = arcPoints[arcPoints.length - 1]; + const nextIsHold = nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1]); + if (i < waypoints.length - 1 && nextLogical) { + if (nextIsHold) { + path.push(toLngLatAlt(nextLogical)); + lastPos = nextLogical; + } else if (radius > 0) { + const arcPoints = this.computeArcPositions(lastPos, currPos, nextLogical, radius); + arcPoints.forEach(p => path.push(toLngLatAlt(p))); + lastPos = arcPoints[arcPoints.length - 1]; + } else { + path.push(toLngLatAlt(currPos)); + lastPos = currPos; + } } else { path.push(toLngLatAlt(currPos)); lastPos = currPos; @@ -3077,11 +3406,12 @@ export default { /** 从 Redis 拉取当前房间+航线+平台的导弹参数并回填表单(支持返回数组时取最后一条作为默认),同时填充 existingMissiles */ fetchMissileParamsFromRedis() { - if (!this.roomId || !this.contextMenu.entityData || this.contextMenu.entityData.type !== 'routePlatform') return + const roomId = this.effectiveRoomId + if (!roomId || !this.contextMenu.entityData || this.contextMenu.entityData.type !== 'routePlatform') return const routeId = this.contextMenu.entityData.routeId const platformId = this.contextMenu.entityData.platformId != null ? this.contextMenu.entityData.platformId : 0 this.existingMissiles = [] - getMissileParams({ roomId: this.roomId, routeId, platformId }).then(res => { + getMissileParams({ roomId, routeId, platformId }).then(res => { let data = res.data if (Array.isArray(data)) { this.existingMissiles = data @@ -3113,13 +3443,14 @@ export default { /** 删除指定索引的导弹(按索引删除,避免按 launchTimeMinutesFromK 匹配时浮点误差导致删错) */ deleteMissile(index) { const entityData = this.contextMenu.entityData - if (!this.roomId || !entityData || entityData.type !== 'routePlatform') return + const roomId = this.effectiveRoomId + if (!roomId || !entityData || entityData.type !== 'routePlatform') return const routeId = entityData.routeId const platformId = entityData.platformId != null ? entityData.platformId : 0 const idx = Number(index) if (idx < 0 || idx >= this.existingMissiles.length) return deleteMissileParams({ - roomId: this.roomId, + roomId, routeId, platformId, index: idx @@ -3223,10 +3554,11 @@ export default { } const launchK = Number.isFinite(Number(this.deductionTimeMinutes)) ? Number(this.deductionTimeMinutes) : 0 - if (this.roomId != null && entityData.routeId != null) { + const roomId = this.effectiveRoomId + if (roomId != null && entityData.routeId != null) { const platformId = entityData.platformId != null ? entityData.platformId : 0 saveMissileParams({ - roomId: this.roomId, + roomId, routeId: entityData.routeId, platformId, angle, diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index d9efc8a..75a1a72 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -1504,6 +1504,7 @@ export default { } catch (_) {} } this.$refs.cesiumMap.renderRouteWaypoints(waypoints, route.id, route.platformId, route.platform, this.parseRouteStyle(route.attributes)); + this.$nextTick(() => this.updateDeductionPositions()); } } catch (_) {} } else { @@ -2142,7 +2143,9 @@ export default { if (updatedWaypoint.labelColor != null) payload.labelColor = updatedWaypoint.labelColor; const response = await updateWaypoints(payload); if (response.code === 200) { - const index = this.selectedRouteDetails.waypoints.findIndex(p => p.id === updatedWaypoint.id); + const roomId = this.currentRoomId; + const sd = this.selectedRouteDetails; + const index = sd.waypoints.findIndex(p => p.id === updatedWaypoint.id); if (index !== -1) { // 更新本地数据(用已提交的 payload 保证 startTime 等与数据库一致) this.selectedRouteDetails.waypoints.splice(index, 1, { ...updatedWaypoint, ...payload }); @@ -2153,8 +2156,6 @@ export default { if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged); } if (this.$refs.cesiumMap) { - const roomId = this.currentRoomId; - const sd = this.selectedRouteDetails; if (roomId && sd.platformId) { try { const styleRes = await getPlatformStyle({ roomId, routeId: sd.id, platformId: sd.platformId }); @@ -3214,8 +3215,9 @@ export default { /** * 按速度与计划时间构建航线时间轴:含飞行段、盘旋段与“提前到达则等待”的等待段。 * pathData 可选:{ path, segmentEndIndices, holdArcRanges },由 getRoutePathWithSegmentIndices 提供,用于输出 hold 段。 + * holdRadiusByLegIndex 可选:{ [legIndex]: number },为盘旋段指定半径(用于推演时落点精准在切点)。 */ - buildRouteTimeline(waypoints, globalMin, globalMax, pathData) { + buildRouteTimeline(waypoints, globalMin, globalMax, pathData, holdRadiusByLegIndex) { const warnings = []; if (!waypoints || waypoints.length === 0) return { segments: [], warnings }; const points = waypoints.map((wp, idx) => ({ @@ -3249,6 +3251,7 @@ export default { const effectiveTime = [points[0].minutes]; const segments = []; const lateArrivalLegs = []; // 无法按时到达的航段,供冲突检测用 + const holdDelayConflicts = []; // 盘旋时间不足:设定时间到但位置未到切出点,实际切出顺延 const path = pathData && pathData.path; const segmentEndIndices = pathData && pathData.segmentEndIndices; const holdArcRanges = pathData && pathData.holdArcRanges || {}; @@ -3264,20 +3267,53 @@ export default { const speedKmh = points[i].speed || 800; const travelToEntryMin = (distToEntry / 1000) * (60 / speedKmh); const arrivalEntry = effectiveTime[i] + travelToEntryMin; - const holdEndTime = points[i + 1].minutes; + const holdEndTime = points[i + 1].minutes; // 用户设定的切出时间(如 K+10) + const exitPos = holdPathSlice.length ? holdPathSlice[holdPathSlice.length - 1] : (toEntrySlice.length ? toEntrySlice[toEntrySlice.length - 1] : { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt }); + let loopEndIdx = 1; + for (let k = 1; k < Math.min(holdPathSlice.length, 120); k++) { + if (this.segmentDistance(holdPathSlice[0], holdPathSlice[k]) < 80) { loopEndIdx = k; break; } + } + const holdClosedLoopPath = holdPathSlice.slice(0, loopEndIdx + 1); + const holdLoopLength = this.pathSliceDistance(holdClosedLoopPath) || 1; + let exitIdxOnLoop = 0; + let minD = 1e9; + for (let k = 0; k <= loopEndIdx; k++) { + const d = this.segmentDistance(holdPathSlice[k], exitPos); + if (d < minD) { minD = d; exitIdxOnLoop = k; } + } + const holdExitDistanceOnLoop = this.pathSliceDistance(holdPathSlice.slice(0, exitIdxOnLoop + 1)); + const speedMpMin = (speedKmh * 1000) / 60; + const requiredDistAtK10 = (holdEndTime - arrivalEntry) * speedMpMin; + let n = Math.ceil((requiredDistAtK10 - holdExitDistanceOnLoop) / holdLoopLength); + if (n < 0 || !Number.isFinite(n)) n = 0; + const segmentEndTime = arrivalEntry + (holdExitDistanceOnLoop + n * holdLoopLength) / speedMpMin; + if (segmentEndTime > holdEndTime) { + const delaySec = Math.round((segmentEndTime - holdEndTime) * 60); + const holdWp = waypoints[i + 1]; + warnings.push(`盘旋「${holdWp.name || 'WP' + (i + 2)}」:到设定时间时未在切出点,继续盘旋至切出点,实际切出将延迟 ${delaySec} 秒。`); + holdDelayConflicts.push({ + legIndex: i, + holdCenter: holdWp ? { lng: parseFloat(holdWp.lng), lat: parseFloat(holdWp.lat), alt: Number(holdWp.alt) || 0 } : null, + setExitTime: holdEndTime, + actualExitTime: segmentEndTime, + delayMinutes: segmentEndTime - holdEndTime, + delaySeconds: delaySec, + fromName: waypoints[i].name, + toName: (waypoints[i + 1] && waypoints[i + 1].name) ? waypoints[i + 1].name : `盘旋${i + 2}` + }); + } const distExitToNext = this.pathSliceDistance(toNextSlice); const travelExitMin = (distExitToNext / 1000) * (60 / speedKmh); - const arrivalNext = holdEndTime + travelExitMin; + const arrivalNext = segmentEndTime + travelExitMin; effectiveTime[i + 1] = holdEndTime; if (i + 2 < points.length) effectiveTime[i + 2] = arrivalNext; const posCur = { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt }; const entryPos = toEntrySlice.length ? toEntrySlice[toEntrySlice.length - 1] : posCur; - const exitPos = holdPathSlice.length ? holdPathSlice[holdPathSlice.length - 1] : entryPos; - const holdDurationMin = holdEndTime - arrivalEntry; const holdWp = waypoints[i + 1]; const holdParams = this.parseHoldParams(holdWp); const holdCenter = holdWp ? { lng: parseFloat(holdWp.lng), lat: parseFloat(holdWp.lat), alt: Number(holdWp.alt) || 0 } : null; - const holdRadius = holdParams && holdParams.radius != null ? holdParams.radius : null; + const overrideR = holdRadiusByLegIndex && holdRadiusByLegIndex[i] != null ? holdRadiusByLegIndex[i] : null; + const holdRadius = (overrideR != null && Number.isFinite(overrideR)) ? overrideR : (holdParams && holdParams.radius != null ? holdParams.radius : null); const holdClockwise = holdParams && holdParams.clockwise !== false; const holdCircumference = holdRadius != null ? 2 * Math.PI * holdRadius : null; const holdEntryAngle = holdCenter && entryPos && holdRadius != null @@ -3286,13 +3322,15 @@ export default { segments.push({ startTime: effectiveTime[i], endTime: arrivalEntry, startPos: posCur, endPos: entryPos, type: 'fly', legIndex: i, pathSlice: toEntrySlice }); segments.push({ startTime: arrivalEntry, - endTime: holdEndTime, + endTime: segmentEndTime, startPos: entryPos, endPos: exitPos, type: 'hold', legIndex: i, holdPath: holdPathSlice, - holdDurationMin, + holdClosedLoopPath, + holdLoopLength, + holdExitDistanceOnLoop, speedKmh: points[i].speed || 800, holdCenter, holdRadius, @@ -3300,7 +3338,7 @@ export default { holdClockwise, holdEntryAngle }); - segments.push({ startTime: holdEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice }); + segments.push({ startTime: segmentEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice }); i++; continue; } @@ -3346,7 +3384,7 @@ export default { earlyArrivalLegs.push({ legIndex: i, scheduled, actualArrival, fromName: waypoints[i].name, toName: waypoints[i + 1].name }); } } - return { segments, warnings, earlyArrivalLegs, lateArrivalLegs }; + return { segments, warnings, earlyArrivalLegs, lateArrivalLegs, holdDelayConflicts }; }, /** 从路径片段中按比例 t(0~1)取点:按弧长比例插值 */ @@ -3398,24 +3436,14 @@ export default { return s.startPos; } if (s.type === 'hold' && s.holdPath && s.holdPath.length) { - const durationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime); - const speedKmh = s.speedKmh != null ? s.speedKmh : 800; - const totalHoldDistM = (speedKmh * (durationMin / 60)) * 1000; - if (s.holdCircumference != null && s.holdCircumference > 0 && s.holdCenter && s.holdRadius != null) { - const currentDistM = t * totalHoldDistM; - const distOnLap = currentDistM % s.holdCircumference; - const angleRad = (distOnLap / s.holdCircumference) * (2 * Math.PI); - const signedAngle = s.holdClockwise ? -angleRad : angleRad; - const entryAngle = s.holdEntryAngle != null ? s.holdEntryAngle : 0; - const angle = entryAngle + signedAngle; - return this.positionOnCircle(s.holdCenter.lng, s.holdCenter.lat, s.holdCenter.alt, s.holdRadius, angle); + // 飞机一直绕闭合环盘旋,不静止;到设定K时若在切出点则切出,否则继续飞到切出点再切出并报冲突 + if (s.holdClosedLoopPath && s.holdClosedLoopPath.length >= 2 && s.holdLoopLength > 0 && s.speedKmh != null) { + const distM = (minutesFromK - s.startTime) * (s.speedKmh * 1000 / 60); + const distOnLoop = ((distM % s.holdLoopLength) + s.holdLoopLength) % s.holdLoopLength; + const tPath = distOnLoop / s.holdLoopLength; + return this.getPositionAlongPathSlice(s.holdClosedLoopPath, tPath); } - const holdPathLen = this.pathSliceDistance(s.holdPath); - if (holdPathLen <= 0) return this.getPositionAlongPathSlice(s.holdPath, t); - const currentDistM = t * totalHoldDistM; - const positionOnLap = currentDistM % holdPathLen; - const tLap = holdPathLen > 0 ? positionOnLap / holdPathLen : 0; - return this.getPositionAlongPathSlice(s.holdPath, tLap); + return this.getPositionAlongPathSlice(s.holdPath, t); } if (s.type === 'fly' && s.pathSlice && s.pathSlice.length) { return this.getPositionAlongPathSlice(s.pathSlice, t); @@ -3436,17 +3464,125 @@ export default { return last.endPos; }, - /** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧/盘旋弧路径运动;返回 { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment },currentSegment 含 speedKmh 用于标牌 */ - getPositionAtMinutesFromK(waypoints, minutesFromK, globalMin, globalMax) { + /** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧/盘旋弧路径运动;盘旋半径由系统根据 k+10 落点反算,使平滑落在切点。routeId 可选,传入时会把计算半径同步给地图以实时渲染盘旋轨迹与切点进入。返回 { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment } */ + getPositionAtMinutesFromK(waypoints, minutesFromK, globalMin, globalMax, routeId) { if (!waypoints || waypoints.length === 0) return { position: null, nextPosition: null, previousPosition: null, warnings: [], earlyArrivalLegs: [], currentSegment: null }; + const cesiumMap = this.$refs.cesiumMap; let pathData = null; - if (this.$refs.cesiumMap && this.$refs.cesiumMap.getRoutePathWithSegmentIndices) { - const ret = this.$refs.cesiumMap.getRoutePathWithSegmentIndices(waypoints); + if (cesiumMap && cesiumMap.getRoutePathWithSegmentIndices) { + const ret = cesiumMap.getRoutePathWithSegmentIndices(waypoints); if (ret.path && ret.path.length > 0 && ret.segmentEndIndices && ret.segmentEndIndices.length > 0) { pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} }; } } - const { segments, warnings, earlyArrivalLegs } = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData); + let { segments, warnings, earlyArrivalLegs } = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData); + const holdRadiusByLegIndex = {}; + const holdEllipseParamsByLegIndex = {}; + if (cesiumMap && segments && pathData) { + for (let idx = 0; idx < segments.length; idx++) { + const s = segments[idx]; + if (s.type !== 'hold' || s.holdCenter == null) continue; + const i = s.legIndex; + const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime); + const speedKmh = s.speedKmh != null ? s.speedKmh : 800; + const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000; + const prevWp = waypoints[i]; + const holdWp = waypoints[i + 1]; + const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp; + if (!prevWp || !holdWp) continue; + const centerCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(holdWp.lng), parseFloat(holdWp.lat), Number(holdWp.alt) || 0); + const prevCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(prevWp.lng), parseFloat(prevWp.lat), Number(prevWp.alt) || 0); + const nextCartesian = nextWp ? Cesium.Cartesian3.fromDegrees(parseFloat(nextWp.lng), parseFloat(nextWp.lat), Number(nextWp.alt) || 0) : centerCartesian; + const clockwise = s.holdClockwise !== false; + const isEllipse = (waypoints[i + 1] && (waypoints[i + 1].pointType || waypoints[i + 1].point_type) === 'hold_ellipse') || s.holdRadius == null; + if (isEllipse && cesiumMap.computeEllipseParamsForDuration) { + const holdParams = this.parseHoldParams(holdWp); + const headingDeg = holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0; + const a0 = holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500; + const b0 = holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300; + const out = cesiumMap.computeEllipseParamsForDuration(centerCartesian, prevCartesian, nextCartesian, clockwise, totalHoldDistM, headingDeg, a0, b0); + if (out && out.semiMajor != null && out.semiMinor != null) holdEllipseParamsByLegIndex[i] = { semiMajor: out.semiMajor, semiMinor: out.semiMinor, headingDeg: headingDeg }; + } else if (!isEllipse && cesiumMap.computeHoldRadiusForDuration) { + const R = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, clockwise, totalHoldDistM); + if (R != null && Number.isFinite(R)) holdRadiusByLegIndex[i] = R; + } + } + const hasCircle = Object.keys(holdRadiusByLegIndex).length > 0; + const hasEllipse = Object.keys(holdEllipseParamsByLegIndex).length > 0; + if (hasCircle || hasEllipse) { + let pathData2 = null; + let segments2 = null; + for (let iter = 0; iter < 2; iter++) { + const ret2 = cesiumMap.getRoutePathWithSegmentIndices(waypoints, { holdRadiusByLegIndex, holdEllipseParamsByLegIndex }); + if (!ret2.path || ret2.path.length === 0 || !ret2.segmentEndIndices || ret2.segmentEndIndices.length === 0) break; + pathData2 = { path: ret2.path, segmentEndIndices: ret2.segmentEndIndices, holdArcRanges: ret2.holdArcRanges || {} }; + const out = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData2, holdRadiusByLegIndex); + segments2 = out.segments; + let changed = false; + if (hasCircle) { + const nextRadii = {}; + for (let idx = 0; idx < segments2.length; idx++) { + const s = segments2[idx]; + if (s.type !== 'hold' || s.holdRadius == null || s.holdCenter == null) continue; + const i = s.legIndex; + const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime); + const speedKmh = s.speedKmh != null ? s.speedKmh : 800; + const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000; + const prevWp = waypoints[i]; + const holdWp = waypoints[i + 1]; + const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp; + if (!prevWp || !holdWp) continue; + const centerCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(holdWp.lng), parseFloat(holdWp.lat), Number(holdWp.alt) || 0); + const prevCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(prevWp.lng), parseFloat(prevWp.lat), Number(prevWp.alt) || 0); + const nextCartesian = nextWp ? Cesium.Cartesian3.fromDegrees(parseFloat(nextWp.lng), parseFloat(nextWp.lat), Number(nextWp.alt) || 0) : centerCartesian; + const Rnew = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, s.holdClockwise !== false, totalHoldDistM); + if (Rnew != null && Number.isFinite(Rnew)) { + if (holdRadiusByLegIndex[i] == null || Math.abs(Rnew - holdRadiusByLegIndex[i]) > 1) changed = true; + nextRadii[i] = Rnew; + } + } + Object.assign(holdRadiusByLegIndex, nextRadii); + } + if (hasEllipse) { + for (let idx = 0; idx < segments2.length; idx++) { + const s = segments2[idx]; + if (s.type !== 'hold' || s.holdRadius != null || s.holdCenter == null) continue; + const i = s.legIndex; + const holdWp = waypoints[i + 1]; + const holdParams = this.parseHoldParams(holdWp); + const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime); + const speedKmh = s.speedKmh != null ? s.speedKmh : 800; + const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000; + const prevWp = waypoints[i]; + const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp; + if (!prevWp || !holdWp || !cesiumMap.computeEllipseParamsForDuration) continue; + const centerCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(holdWp.lng), parseFloat(holdWp.lat), Number(holdWp.alt) || 0); + const prevCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(prevWp.lng), parseFloat(prevWp.lat), Number(prevWp.alt) || 0); + const nextCartesian = nextWp ? Cesium.Cartesian3.fromDegrees(parseFloat(nextWp.lng), parseFloat(nextWp.lat), Number(nextWp.alt) || 0) : centerCartesian; + const headingDeg = holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0; + const a0 = holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500; + const b0 = holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300; + const out = cesiumMap.computeEllipseParamsForDuration(centerCartesian, prevCartesian, nextCartesian, s.holdClockwise !== false, totalHoldDistM, headingDeg, a0, b0); + if (out && out.semiMajor != null) { + const old = holdEllipseParamsByLegIndex[i]; + if (!old || Math.abs(out.semiMajor - old.semiMajor) > 1) changed = true; + holdEllipseParamsByLegIndex[i] = { semiMajor: out.semiMajor, semiMinor: out.semiMinor, headingDeg: headingDeg }; + } + } + } + if (!changed || iter === 1) break; + } + if (pathData2) pathData = pathData2; + if (segments2) segments = segments2; + if (routeId != null) { + if (cesiumMap.setRouteHoldRadii) cesiumMap.setRouteHoldRadii(routeId, holdRadiusByLegIndex); + if (cesiumMap.setRouteHoldEllipseParams) cesiumMap.setRouteHoldEllipseParams(routeId, holdEllipseParamsByLegIndex); + } + } else if (routeId != null) { + if (cesiumMap.setRouteHoldRadii) cesiumMap.setRouteHoldRadii(routeId, {}); + if (cesiumMap.setRouteHoldEllipseParams) cesiumMap.setRouteHoldEllipseParams(routeId, {}); + } + } const path = pathData ? pathData.path : null; const segmentEndIndices = pathData ? pathData.segmentEndIndices : null; const position = this.getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices); @@ -3495,7 +3631,7 @@ export default { this.activeRouteIds.forEach(routeId => { const route = this.routes.find(r => r.id === routeId); if (!route || !route.waypoints || route.waypoints.length === 0) return; - const { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes); + const { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes, routeId); if (warnings && warnings.length) allWarnings.push(...warnings); if (position) { const directionPoint = nextPosition || previousPosition; @@ -3684,6 +3820,7 @@ export default { } this.$refs.cesiumMap.renderRouteWaypoints(waypoints, route.id, route.platformId, route.platform, this.parseRouteStyle(route.attributes)); } + this.$nextTick(() => this.updateDeductionPositions()); } catch (_) {} } const firstId = planRouteIds[0]; @@ -3786,6 +3923,7 @@ export default { } catch (_) {} } this.$refs.cesiumMap.renderRouteWaypoints(waypoints, route.id, route.platformId, route.platform, this.parseRouteStyle(route.attributes)); + this.$nextTick(() => this.updateDeductionPositions()); } } else { this.$message.warning('该航线暂无坐标数据,无法在地图展示'); @@ -3964,7 +4102,7 @@ export default { pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} }; } } - const { earlyArrivalLegs, lateArrivalLegs } = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, pathData); + const { earlyArrivalLegs, lateArrivalLegs, holdDelayConflicts } = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, pathData); const routeName = route.name || `航线${route.id}`; (earlyArrivalLegs || []).forEach(leg => { @@ -3990,6 +4128,20 @@ export default { severity: 'high' }); }); + (holdDelayConflicts || []).forEach(conf => { + list.push({ + id: id++, + title: '盘旋时间不足', + routeName, + fromWaypoint: conf.fromName, + toWaypoint: conf.toName, + time: this.minutesToStartTime(conf.setExitTime), + position: conf.holdCenter ? `经度 ${conf.holdCenter.lng.toFixed(5)}°, 纬度 ${conf.holdCenter.lat.toFixed(5)}°` : undefined, + suggestion: `警告:设定的盘旋时间不足以支撑战斗机完成最后一圈,实际切出将延迟 ${conf.delaySeconds} 秒。`, + severity: 'high', + holdCenter: conf.holdCenter + }); + }); }); this.conflicts = list; diff --git a/ruoyi-ui/vue.config.js b/ruoyi-ui/vue.config.js index 7ab699a..505626c 100644 --- a/ruoyi-ui/vue.config.js +++ b/ruoyi-ui/vue.config.js @@ -15,7 +15,7 @@ const CompressionPlugin = require('compression-webpack-plugin') const CopyWebpackPlugin = require('copy-webpack-plugin') const name = process.env.VUE_APP_TITLE || '若依管理系统' // 网页标题 -const baseUrl = 'http://127.0.0.1:8080' // 后端接口 +const baseUrl = 'http://192.168.1.107:8080' // 后端接口 const port = process.env.port || process.env.npm_config_port || 80 // 端口 // 定义 Cesium 源码路径 From 0fdb456e2cc347a97be6f25d450a7ecbda1d988e Mon Sep 17 00:00:00 2001 From: menghao <1584479611@qq.com> Date: Mon, 9 Mar 2026 14:40:14 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=88=AA=E7=BA=BF=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E7=AD=89=E6=A0=B7=E5=BC=8F=E6=98=BE=E7=A4=BA=E3=80=81=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E9=A1=B9=E6=98=BE=E7=A4=BA=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E3=80=81=E6=8E=A7=E5=88=B6=E5=9C=B0=E5=9B=BE=E6=8B=96?= =?UTF-8?q?=E5=8A=A8=E5=B0=8F=E6=89=8B=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ruoyi/system/domain/RouteWaypoints.java | 65 +++++- .../ruoyi/system/domain/WaypointDisplayStyle.java | 62 ++++++ .../WaypointDisplayStyleTypeHandler.java | 59 +++++ .../mapper/system/RouteWaypointsMapper.xml | 14 +- .../system/route_waypoints_display_style_json.sql | 27 +++ ruoyi-ui/.env.development | 2 +- ruoyi-ui/src/layout/components/TagsView/index.vue | 8 +- ruoyi-ui/src/views/cesiumMap/ContextMenu.vue | 19 +- ruoyi-ui/src/views/cesiumMap/index.vue | 240 +++++++++++++++------ ruoyi-ui/src/views/childRoom/TopHeader.vue | 47 ++++ ruoyi-ui/src/views/childRoom/index.vue | 17 +- ruoyi-ui/src/views/dialogs/RouteEditDialog.vue | 67 +++++- ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue | 104 +++++++-- ruoyi-ui/src/views/selectRoom/index.vue | 15 +- ruoyi-ui/vue.config.js | 2 +- 15 files changed, 618 insertions(+), 130 deletions(-) create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/typehandler/WaypointDisplayStyleTypeHandler.java create mode 100644 ruoyi-system/src/main/resources/mapper/system/route_waypoints_display_style_json.sql diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java index b2aea1b..eca27b8 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java @@ -1,6 +1,7 @@ package com.ruoyi.system.domain; import java.math.BigDecimal; +import com.fasterxml.jackson.annotation.JsonIgnore; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; import com.ruoyi.common.annotation.Excel; @@ -63,11 +64,8 @@ public class RouteWaypoints extends BaseEntity @Excel(name = "盘旋参数") private String holdParams; - /** 航点标签文字大小(px),用于地图显示 */ - private Integer labelFontSize; - - /** 航点标签文字颜色(如 #333333),用于地图显示 */ - private String labelColor; + /** 航点显示样式 JSON:字号、文字颜色、标记大小与颜色等,对应表 display_style 列 */ + private WaypointDisplayStyle displayStyle; public void setId(Long id) { @@ -185,20 +183,66 @@ public class RouteWaypoints extends BaseEntity return holdParams; } + @JsonIgnore + public WaypointDisplayStyle getDisplayStyle() { + return displayStyle; + } + + @JsonIgnore + public void setDisplayStyle(WaypointDisplayStyle displayStyle) { + this.displayStyle = displayStyle; + } + + private WaypointDisplayStyle getOrCreateDisplayStyle() { + if (displayStyle == null) { + displayStyle = new WaypointDisplayStyle(); + } + return displayStyle; + } + + /** API/前端:地图上航点名称字号,默认 16 */ public void setLabelFontSize(Integer labelFontSize) { - this.labelFontSize = labelFontSize; + getOrCreateDisplayStyle().setLabelFontSize(labelFontSize); } public Integer getLabelFontSize() { - return labelFontSize; + return displayStyle != null && displayStyle.getLabelFontSize() != null ? displayStyle.getLabelFontSize() : 16; } + /** API/前端:地图上航点名称颜色,默认 #000000 */ public void setLabelColor(String labelColor) { - this.labelColor = labelColor; + getOrCreateDisplayStyle().setLabelColor(labelColor); } public String getLabelColor() { - return labelColor; + return displayStyle != null && displayStyle.getLabelColor() != null ? displayStyle.getLabelColor() : "#000000"; + } + + /** API/前端:航点圆点填充色,默认 #ffffff */ + public void setColor(String color) { + getOrCreateDisplayStyle().setColor(color); + } + + public String getColor() { + return displayStyle != null && displayStyle.getColor() != null ? displayStyle.getColor() : "#ffffff"; + } + + /** API/前端:航点圆点直径(像素),默认 12 */ + public void setPixelSize(Integer pixelSize) { + getOrCreateDisplayStyle().setPixelSize(pixelSize); + } + + public Integer getPixelSize() { + return displayStyle != null && displayStyle.getPixelSize() != null ? displayStyle.getPixelSize() : 12; + } + + /** API/前端:航点圆点边框颜色,默认 #000000 */ + public void setOutlineColor(String outlineColor) { + getOrCreateDisplayStyle().setOutlineColor(outlineColor); + } + + public String getOutlineColor() { + return displayStyle != null && displayStyle.getOutlineColor() != null ? displayStyle.getOutlineColor() : "#000000"; } @Override @@ -216,8 +260,7 @@ public class RouteWaypoints extends BaseEntity .append("turnAngle", getTurnAngle()) .append("pointType", getPointType()) .append("holdParams", getHoldParams()) - .append("labelFontSize", getLabelFontSize()) - .append("labelColor", getLabelColor()) + .append("displayStyle", displayStyle) .toString(); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java new file mode 100644 index 0000000..7371896 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java @@ -0,0 +1,62 @@ +package com.ruoyi.system.domain; + +import java.io.Serializable; + +/** + * 航点显示样式(地图上名称字号/颜色、标记大小/颜色),对应表字段 display_style 的 JSON 结构。 + */ +public class WaypointDisplayStyle implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 航点名称在地图上的字号,默认 16 */ + private Integer labelFontSize; + /** 航点名称在地图上的颜色,默认 #000000 */ + private String labelColor; + /** 航点圆点填充色,默认 #ffffff */ + private String color; + /** 航点圆点直径(像素),默认 12 */ + private Integer pixelSize; + /** 航点圆点边框颜色,默认 #000000 */ + private String outlineColor; + + public Integer getLabelFontSize() { + return labelFontSize; + } + + public void setLabelFontSize(Integer labelFontSize) { + this.labelFontSize = labelFontSize; + } + + public String getLabelColor() { + return labelColor; + } + + public void setLabelColor(String labelColor) { + this.labelColor = labelColor; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + public Integer getPixelSize() { + return pixelSize; + } + + public void setPixelSize(Integer pixelSize) { + this.pixelSize = pixelSize; + } + + public String getOutlineColor() { + return outlineColor; + } + + public void setOutlineColor(String outlineColor) { + this.outlineColor = outlineColor; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/typehandler/WaypointDisplayStyleTypeHandler.java b/ruoyi-system/src/main/java/com/ruoyi/system/typehandler/WaypointDisplayStyleTypeHandler.java new file mode 100644 index 0000000..616f150 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/typehandler/WaypointDisplayStyleTypeHandler.java @@ -0,0 +1,59 @@ +package com.ruoyi.system.typehandler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.system.domain.WaypointDisplayStyle; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedTypes; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * MyBatis 类型处理器:display_style 列(TEXT/JSON)与 WaypointDisplayStyle 互转。 + */ +@MappedTypes(WaypointDisplayStyle.class) +public class WaypointDisplayStyleTypeHandler extends BaseTypeHandler { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, WaypointDisplayStyle parameter, JdbcType jdbcType) throws SQLException { + try { + ps.setString(i, MAPPER.writeValueAsString(parameter)); + } catch (Exception e) { + throw new SQLException("WaypointDisplayStyle serialize error", e); + } + } + + @Override + public WaypointDisplayStyle getNullableResult(ResultSet rs, String columnName) throws SQLException { + String json = rs.getString(columnName); + return parse(json); + } + + @Override + public WaypointDisplayStyle getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + String json = rs.getString(columnIndex); + return parse(json); + } + + @Override + public WaypointDisplayStyle getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + String json = cs.getString(columnIndex); + return parse(json); + } + + private static WaypointDisplayStyle parse(String json) { + if (json == null || json.trim().isEmpty()) { + return null; + } + try { + return MAPPER.readValue(json, WaypointDisplayStyle.class); + } catch (Exception e) { + return null; + } + } +} diff --git a/ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml b/ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml index 7971efc..707b059 100644 --- a/ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml @@ -17,12 +17,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - - + - select id, route_id, name, seq, lat, lng, alt, speed, start_time, turn_angle, point_type, hold_params, label_font_size, label_color from route_waypoints + select id, route_id, name, seq, lat, lng, alt, speed, start_time, turn_angle, point_type, hold_params, display_style from route_waypoints