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 eca27b8..298f6d0 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 @@ -245,6 +245,33 @@ public class RouteWaypoints extends BaseEntity return displayStyle != null && displayStyle.getOutlineColor() != null ? displayStyle.getOutlineColor() : "#000000"; } + /** 航段模式:fixed_speed-定速,fixed_time-定时,空或null-普通 */ + public String getSegmentMode() { + return displayStyle != null ? displayStyle.getSegmentMode() : null; + } + + public void setSegmentMode(String segmentMode) { + getOrCreateDisplayStyle().setSegmentMode(segmentMode); + } + + /** 定时目标(分):fixed_time 时表示期望到达本航点的相对K时,可与相对K时分离支持“定时到达+盘旋至相对K时出发” */ + public Double getSegmentTargetMinutes() { + return displayStyle != null ? displayStyle.getSegmentTargetMinutes() : null; + } + + public void setSegmentTargetMinutes(Double segmentTargetMinutes) { + getOrCreateDisplayStyle().setSegmentTargetMinutes(segmentTargetMinutes); + } + + /** 定速目标(km/h):fixed_speed 时表示本航段固定速度 */ + public Double getSegmentTargetSpeed() { + return displayStyle != null ? displayStyle.getSegmentTargetSpeed() : null; + } + + public void setSegmentTargetSpeed(Double segmentTargetSpeed) { + getOrCreateDisplayStyle().setSegmentTargetSpeed(segmentTargetSpeed); + } + @Override public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) 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 index 7371896..e7433f0 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java @@ -19,6 +19,12 @@ public class WaypointDisplayStyle implements Serializable { private Integer pixelSize; /** 航点圆点边框颜色,默认 #000000 */ private String outlineColor; + /** 航段模式:fixed_speed-定速(移动下一航点改下一航点相对K时), fixed_time-定时(移动本航点改上一航点速度), 空或null-普通 */ + private String segmentMode; + /** 定时目标(分):fixed_time 时表示期望到达本航点的相对K时(分),与相对K时分离后可支持“定时到达+盘旋至相对K时出发” */ + private Double segmentTargetMinutes; + /** 定速目标(km/h):fixed_speed 时表示本航段使用的固定速度,用于重算下一航点相对K时 */ + private Double segmentTargetSpeed; public Integer getLabelFontSize() { return labelFontSize; @@ -59,4 +65,28 @@ public class WaypointDisplayStyle implements Serializable { public void setOutlineColor(String outlineColor) { this.outlineColor = outlineColor; } + + public String getSegmentMode() { + return segmentMode; + } + + public void setSegmentMode(String segmentMode) { + this.segmentMode = segmentMode; + } + + public Double getSegmentTargetMinutes() { + return segmentTargetMinutes; + } + + public void setSegmentTargetMinutes(Double segmentTargetMinutes) { + this.segmentTargetMinutes = segmentTargetMinutes; + } + + public Double getSegmentTargetSpeed() { + return segmentTargetSpeed; + } + + public void setSegmentTargetSpeed(Double segmentTargetSpeed) { + this.segmentTargetSpeed = segmentTargetSpeed; + } } diff --git a/ruoyi-system/src/main/resources/mapper/system/route_waypoints_display_style_json.sql b/ruoyi-system/src/main/resources/mapper/system/route_waypoints_display_style_json.sql deleted file mode 100644 index e304b4d..0000000 --- a/ruoyi-system/src/main/resources/mapper/system/route_waypoints_display_style_json.sql +++ /dev/null @@ -1,27 +0,0 @@ --- 将航点显示相关字段合并为单列 JSON:display_style --- 执行前请备份。若表带 schema(如 ry.route_waypoints)请自行替换表名。 - --- 1. 新增 JSON 列 -ALTER TABLE route_waypoints - ADD COLUMN display_style JSON DEFAULT NULL COMMENT '航点显示样式JSON: labelFontSize,labelColor,color,pixelSize,outlineColor'; - --- 2. 从旧列回填(仅存在 label_font_size / label_color 时) -UPDATE route_waypoints -SET display_style = JSON_OBJECT( - 'labelFontSize', COALESCE(label_font_size, 16), - 'labelColor', COALESCE(label_color, '#000000'), - 'color', '#ffffff', - 'pixelSize', 12, - 'outlineColor', '#000000' -) -WHERE display_style IS NULL; - --- 3. 删除旧列(若你曾加过 color/pixel_size/outline_color 三列,也一并删除) -ALTER TABLE route_waypoints - DROP COLUMN label_font_size, - DROP COLUMN label_color; - --- 若存在以下列则逐条执行(没有则跳过): --- ALTER TABLE route_waypoints DROP COLUMN color; --- ALTER TABLE route_waypoints DROP COLUMN pixel_size; --- ALTER TABLE route_waypoints DROP COLUMN outline_color; diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 088f4f0..e19d3ba 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -601,10 +601,8 @@ export default { // 空域位置调整:{ entityData },预览实体用 CallbackProperty 实时跟随鼠标(与测距绘制一致) airspacePositionEditContext: null, airspacePositionEditPreviewEntity: null, - // 盘旋最小尺寸(米):圆半径 3km,椭圆长半轴 5km、短半轴 3.5km - MIN_HOLD_RADIUS_M: 3000, - MIN_ELLIPSE_SEMI_MAJOR_M: 5000, - MIN_ELLIPSE_SEMI_MINOR_M: 3500 + // 跑道形“椭圆”盘旋:两条直边 + 两段弧。边长默认 20km;弧转弯半径由航点转弯坡度+速度计算,不再单独存储 + DEFAULT_RACE_TRACK_EDGE_LENGTH_M: 20000 } }, components: { @@ -1428,24 +1426,43 @@ export default { const center = this.missionPendingHold.center; const p = holdParams; const isCircle = p.radius != null; + const isRaceTrack = !isCircle && p.edgeLength != null; const radius = isCircle ? (p.radius || 500) : 500; const semiMajor = !isCircle ? (p.semiMajor ?? p.semiMajorAxis ?? 500) : radius; const semiMinor = !isCircle ? (p.semiMinor ?? p.semiMinorAxis ?? 300) : radius; const headingRad = !isCircle ? ((p.headingDeg ?? 0) * Math.PI) / 180 : 0; - this.tempHoldOutlineEntity = this.viewer.entities.add({ - id: 'temp_hold_outline', - position: center, - ellipse: { - semiMajorAxis: semiMajor, - semiMinorAxis: semiMinor, - rotation: headingRad, - material: Cesium.Color.TRANSPARENT, - outline: true, - outlineColor: Cesium.Color.ORANGE.withAlpha(0.8), - outlineWidth: 2, - arcType: Cesium.ArcType.NONE - } - }); + if (isRaceTrack) { + const edgeLengthM = Math.max(1000, p.edgeLength ?? this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M); + const arcRadiusM = this.getWaypointRadius({ speed: p.speed ?? 800, turnAngle: p.turnAngle ?? 45 }) || 500; + const clockwise = p.clockwise !== false; + const prev = this.drawingPoints && this.drawingPoints.length >= 1 ? this.drawingPoints[this.drawingPoints.length - 1] : null; + const directionRad = this.getRaceTrackDirectionRad(center, prev, null, p.headingDeg ?? 0); + const trackPositions = this.getRaceTrackLoopPositions(center, directionRad, edgeLengthM, arcRadiusM, clockwise, 24); + this.tempHoldOutlineEntity = this.viewer.entities.add({ + id: 'temp_hold_outline', + polyline: { + positions: trackPositions, + width: 2, + material: Cesium.Color.ORANGE.withAlpha(0.8), + arcType: Cesium.ArcType.NONE + } + }); + } else { + this.tempHoldOutlineEntity = this.viewer.entities.add({ + id: 'temp_hold_outline', + position: center, + ellipse: { + semiMajorAxis: semiMajor, + semiMinorAxis: semiMinor, + rotation: headingRad, + material: Cesium.Color.TRANSPARENT, + outline: true, + outlineColor: Cesium.Color.ORANGE.withAlpha(0.8), + outlineWidth: 2, + arcType: Cesium.ArcType.NONE + } + }); + } this.tempHoldEntity = this.viewer.entities.add({ id: 'temp_hold_preview', name: 'HOLD', @@ -2084,25 +2101,31 @@ export default { }); if (!this._routeWaypointIdsByRoute) this._routeWaypointIdsByRoute = {}; this._routeWaypointIdsByRoute[routeId] = waypoints.map((wp) => wp.id); + if (!this._routeWaypointsByRoute) this._routeWaypointsByRoute = {}; + this._routeWaypointsByRoute[routeId] = waypoints; if (!this._routeHoldRadiiByRoute) this._routeHoldRadiiByRoute = {}; if (!this._routeHoldEllipseParamsByRoute) this._routeHoldEllipseParamsByRoute = {}; // 判断航点 i 是否为“转弯半径”航点(将用弧线两端两个点替代中心点);非首尾默认 45° 坡度 const isTurnWaypointWithArc = (i) => { if (i < 1 || i >= waypoints.length - 1) return false; + // 下一航点是盘旋点时,不隐藏当前航点(只画直线到盘旋入口,不画转弯弧),否则会丢失航点显示 + if (this.isHoldWaypoint(waypoints[i + 1])) return false; const wp = waypoints[i]; const effectiveAngle = this.getEffectiveTurnAngle(wp, i, waypoints.length); if (this.getWaypointRadius({ ...wp, turnAngle: effectiveAngle }) <= 0) return false; const nextPos = originalPositions[i + 1]; 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.getCircleTangentEntryPoint(originalPositions[i + 1], originalPositions[i], r, clock) - : this.getEllipseTangentEntryPoint(originalPositions[i + 1], originalPositions[i], smj, smn, ((holdParams.headingDeg || 0) * Math.PI) / 180, clock); + const nextWp = waypoints[i + 1]; + const holdParams = this.parseHoldParams(nextWp); + const nextHoldIsCircle = (nextWp.pointType || nextWp.point_type) === 'hold_circle'; + if (nextHoldIsCircle) { + const clock = holdParams && holdParams.clockwise !== false; + const r = holdParams && holdParams.radius != null ? holdParams.radius : 500; + nextLogical = this.getCircleTangentEntryPoint(originalPositions[i + 1], originalPositions[i], r, clock); + } else { + nextLogical = originalPositions[i + 1]; + } } return !!nextLogical; }; @@ -2395,14 +2418,17 @@ export default { if (this.isHoldWaypoint(wp)) { const params = this.parseHoldParams(wp); 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) + const pt = wp.pointType || wp.point_type; + const useCircle = pt === 'hold_circle'; + const defaultRadius = params && params.radius != null ? params.radius : 500; + const radius = (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); + : defaultRadius; + const defaultSemiMajor = params && (params.semiMajor != null || params.semiMajorAxis != null) ? (params.semiMajor ?? params.semiMajorAxis) : 500; + const defaultSemiMinor = 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 edgeLengthM = Math.max(1000, params && params.edgeLength != null ? params.edgeLength : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M); + const arcRadiusM = this.getWaypointRadius({ ...wp, turnAngle: this.getEffectiveTurnAngle(wp, i, waypoints.length) }) || 500; const clockwise = params && params.clockwise !== false; const currPosCloned = Cesium.Cartesian3.clone(currPos); const lastPosCloned = Cesium.Cartesian3.clone(lastPos); @@ -2421,37 +2447,47 @@ export default { 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); + // 圆形盘旋:先画闭合整圆(360°),再接 entry→exit 弧段以正确连到下一航段 + const entryAngle = (() => { + 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 toP = Cesium.Cartesian3.subtract(entry, currPosCloned, new Cesium.Cartesian3()); + return Math.atan2(Cesium.Cartesian3.dot(toP, east), Cesium.Cartesian3.dot(toP, north)); + })(); + const circlePts = that.getCircleFullCircle(currPosCloned, R, entryAngle, clockwise, 64); + const arcToExit = that.getCircleArcEntryToExit(currPosCloned, R, entry, exit, clockwise, 24); + arcPoints = circlePts && circlePts.length + ? (arcToExit && arcToExit.length > 1 ? [...circlePts, ...arcToExit.slice(1)] : circlePts) + : (arcToExit && arcToExit.length ? arcToExit : [Cesium.Cartesian3.clone(entry), Cesium.Cartesian3.clone(exit)]); + return arcPoints; } 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); + const fullCirclePoints = that.getEllipseFullCircle(currPosCloned, smj, smn, hd, entryLocalAngle, clockwise, 128); + arcPoints = that.buildEllipseHoldArc(currPosCloned, smj, smn, hd, entry, exit, clockwise, 120); + return [entry, ...(fullCirclePoints || []).slice(1), ...(arcPoints || []).slice(1)]; } - 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 }); + const raceTrackDirectionRad = this.getRaceTrackDirectionRad( + currPosCloned, + lastPosCloned, + nextPosCloned, + params && params.headingDeg != null ? params.headingDeg : 0 + ); + const buildRaceTrackPositions = () => that.getRaceTrackLoopPositions(currPosCloned, raceTrackDirectionRad, edgeLengthM, arcRadiusM, clockwise, 24); + const holdPositions = useCircle ? buildHoldPositions(radius) : buildRaceTrackPositions(); for (let k = 0; k < holdPositions.length; k++) finalPathPositions.push(holdPositions[k]); const getHoldPositions = () => { if (useCircle) { - const R = Math.max(that.MIN_HOLD_RADIUS_M, (that._routeHoldRadiiByRoute && that._routeHoldRadiiByRoute[routeIdHold] && that._routeHoldRadiiByRoute[routeIdHold][legIndexHold] != null) + const R = (that._routeHoldRadiiByRoute && that._routeHoldRadiiByRoute[routeIdHold] && that._routeHoldRadiiByRoute[routeIdHold][legIndexHold] != null) ? that._routeHoldRadiiByRoute[routeIdHold][legIndexHold] - : defaultRadius); + : 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) }); + return buildRaceTrackPositions(); }; this.viewer.entities.add({ id: `hold-line-${routeId}-${i}`, @@ -2465,22 +2501,32 @@ export default { }, properties: { routeId: routeId } }); - lastPos = holdPositions[holdPositions.length - 1]; + if (!useCircle && nextPosCloned) { + lastPos = this.getRaceTrackTangentExitPoint(currPosCloned, nextPosCloned, raceTrackDirectionRad, edgeLengthM, arcRadiusM, clockwise); + } else { + 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]); - const holdRadius = Math.max(this.MIN_HOLD_RADIUS_M, (this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId] && this._routeHoldRadiiByRoute[routeId][i] != null) + const nextWp = waypoints[i + 1]; + const holdParams = this.parseHoldParams(nextWp); + const nextHoldIsCircle = (nextWp.pointType || nextWp.point_type) === 'hold_circle'; + if (nextHoldIsCircle) { + const holdRadius = (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); + : (holdParams && holdParams.radius != null ? holdParams.radius : 500); + const holdClock = holdParams && holdParams.clockwise !== false; + nextLogical = this.getCircleTangentEntryPoint(originalPositions[i + 1], lastPos, holdRadius, holdClock); + } else { + const nextNext = i + 2 < waypoints.length ? originalPositions[i + 2] : null; + const nextDir = this.getRaceTrackDirectionRad(originalPositions[i + 1], currPos, nextNext, holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0); + const nextEdge = Math.max(1000, holdParams && holdParams.edgeLength != null ? holdParams.edgeLength : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M); + const nextArcR = this.getWaypointRadius({ ...nextWp, turnAngle: this.getEffectiveTurnAngle(nextWp, i + 1, waypoints.length) }) || 500; + nextLogical = this.getRaceTrackTangentEntryPoint(originalPositions[i + 1], lastPos, nextDir, nextEdge, nextArcR, holdParams && holdParams.clockwise !== false); + } } if (i < waypoints.length - 1 && nextLogical) { const lastPosCloned = Cesium.Cartesian3.clone(lastPos); @@ -2552,7 +2598,12 @@ export default { // 先加一层透明加宽“点击区域”,便于左键点中航线(细线难触发) const that = this; const hitLineId = lineId + '-hit'; + const hasHold = waypoints.some((wp) => that.isHoldWaypoint(wp)); const routePositionsCallback = new Cesium.CallbackProperty(function () { + if (hasHold) { + const pathPos = that.getRoutePathPositionsForLine(routeId); + if (pathPos && pathPos.length > 0) return pathPos; + } return that.getRouteLinePositionsFromWaypointEntities(routeId) || finalPathPositions; }, false); const hitEntity = this.viewer.entities.add({ @@ -2588,6 +2639,21 @@ export default { } } }, + /** 获取与推演位置计算一致的完整航线路径(含盘旋),保证平台图标严格沿航线运动 */ + getRoutePathPositionsForLine(routeId) { + const waypoints = this._routeWaypointsByRoute && this._routeWaypointsByRoute[routeId]; + if (!waypoints || waypoints.length < 1 || !this.viewer) return null; + const holdRadii = this._routeHoldRadiiByRoute && this._routeHoldRadiiByRoute[routeId]; + const holdEllipse = this._routeHoldEllipseParamsByRoute && this._routeHoldEllipseParamsByRoute[routeId]; + const opts = { holdRadiusByLegIndex: holdRadii || {}, holdEllipseParamsByLegIndex: holdEllipse || {} }; + const pathData = this.getRoutePathWithSegmentIndices(waypoints, opts); + if (!pathData.path || pathData.path.length < 1) return null; + const ellipsoid = this.viewer.scene.globe.ellipsoid; + return pathData.path.map(p => + Cesium.Cartesian3.fromDegrees(Number(p.lng), Number(p.lat), Number(p.alt != null ? p.alt : 0), ellipsoid) + ); + }, + /** 从各航点/弧线/盘旋实体取当前位置,供主航线折线实时连线(拖拽时动态跟随)。转弯处优先用弧线,不经过航点中心,只展示转弯半径 */ getRouteLinePositionsFromWaypointEntities(routeId) { const ids = this._routeWaypointIdsByRoute && this._routeWaypointIdsByRoute[routeId]; @@ -2676,19 +2742,356 @@ export default { semiMajor: p.semiMajor ?? p.semiMajorAxis, semiMinor: p.semiMinor ?? p.semiMinorAxis, headingDeg: p.headingDeg ?? 0, - clockwise: p.clockwise !== false + clockwise: p.clockwise !== false, + edgeLength: p.edgeLength != null ? Number(p.edgeLength) : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M }; } catch (e) { return null; } }, - /** 圆上整圈(360°)采样,从 startAngleRad 起按顺时针/逆时针,用于盘旋段渲染为整圆 */ + /** + * 跑道形轴线方向角(弧度)。椭圆盘旋:飞机到达该航点时依旧以当前速度方向航行,直至设定边长,该段即跑道第一边。 + * 故轴线 = 进近方向(上一航点→本航点)。无上一航点时用下一航点方向或航向角。 + */ + getRaceTrackDirectionRad(centerCartesian, lastPos, nextPos, headingDegDefault) { + const deg = headingDegDefault != null ? Number(headingDegDefault) : 0; + const defaultRad = (deg * Math.PI) / 180; + if (!centerCartesian) return defaultRad; + if (lastPos) { + const toCurr = Cesium.Cartesian3.subtract(centerCartesian, lastPos, new Cesium.Cartesian3()); + if (Cesium.Cartesian3.magnitude(toCurr) > 1e-6) { + 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()); + return Math.atan2(Cesium.Cartesian3.dot(toCurr, east), Cesium.Cartesian3.dot(toCurr, north)); + } + } + if (nextPos) { + const toNext = Cesium.Cartesian3.subtract(nextPos, centerCartesian, new Cesium.Cartesian3()); + if (Cesium.Cartesian3.magnitude(toNext) > 1e-6) { + 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()); + return Math.atan2(Cesium.Cartesian3.dot(toNext, east), Cesium.Cartesian3.dot(toNext, north)); + } + } + return defaultRad; + }, + + /** + * 椭圆跑道(Loiter)轨迹点数组:在 ENU 局部坐标系中计算,保证顺/逆时针偏向正确。 + * - 进入航向 dir:轴线方向(上一航点→本航点)。 + * - 顺时针:另一长边与圆心在 dir 的右侧(法向量方向);圆弧角度递减(减 step)。 + * - 逆时针:另一长边与圆心在 dir 的左侧(法向量反方向);圆弧角度递增(加 step)。 + * 路径:[直线1 -> 半圆1 -> 直线2 -> 半圆2] 闭合到起点。 + * @returns {Cesium.Cartesian3[]} + */ + calculateLoiterPath(centerCartesian, directionRad, edgeLengthM, arcRadiusM, clockwise, numPointsPerArc) { + if (!centerCartesian || edgeLengthM <= 0 || arcRadiusM <= 0) return []; + const R = Math.min(Math.max(Number(arcRadiusM), 1), edgeLengthM / 2); + const dir = Number.isFinite(directionRad) ? directionRad : 0; + const L = edgeLengthM; + + 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 origin = Cesium.Cartesian3.clone(centerCartesian); + + const toCartesian = (eastM, northM) => + Cesium.Cartesian3.add( + Cesium.Cartesian3.add( + Cesium.Cartesian3.clone(origin), + Cesium.Cartesian3.multiplyByScalar(east, eastM, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ), + Cesium.Cartesian3.multiplyByScalar(north, northM, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + + // 进入航向单位向量 (east, north),0=北 + const dirE = Math.sin(dir); + const dirN = Math.cos(dir); + // 航向右侧法向量(顺时针时弧在这一侧) + const rightE = Math.cos(dir); + const rightN = -Math.sin(dir); + // 区分偏移方向:顺时针=右侧(+1),逆时针=左侧(-1) + const side = clockwise ? 1 : -1; + const offsetE = side * rightE; + const offsetN = side * rightN; + + const A = { e: 0, n: 0 }; + const B = { e: L * dirE, n: L * dirN }; + const center1 = { e: B.e + R * offsetE, n: B.n + R * offsetN }; + const C = { e: B.e + 2 * R * offsetE, n: B.n + 2 * R * offsetN }; + const D_pt = { e: C.e - L * dirE, n: C.n - L * dirN }; + const center2 = { e: A.e + R * offsetE, n: A.n + R * offsetN }; + + const nArc = Math.max(8, numPointsPerArc || 24); + const semicircle = (centerENU, fromENU, toENU, isClockwise) => { + const dFromE = fromENU.e - centerENU.e; + const dFromN = fromENU.n - centerENU.n; + const dToE = toENU.e - centerENU.e; + const dToN = toENU.n - centerENU.n; + let a0 = Math.atan2(dFromE, dFromN); + let a1 = Math.atan2(dToE, dToN); + let d = a1 - a0; + if (d > Math.PI) d -= 2 * Math.PI; + if (d < -Math.PI) d += 2 * Math.PI; + // 这里刻意与之前逻辑“反向”:当前 d 产生的半圆开口向内,将条件翻转后得到向外鼓出的半圆。 + if (isClockwise) { + if (d <= 0) d += 2 * Math.PI; + } else { + if (d >= 0) d -= 2 * Math.PI; + } + const pts = []; + for (let i = 1; i < nArc; i++) { + const t = i / nArc; + const a = a0 + d * t; + pts.push(toCartesian(centerENU.e + R * Math.sin(a), centerENU.n + R * Math.cos(a))); + } + return pts; + }; + + const points = []; + points.push(toCartesian(A.e, A.n)); + points.push(toCartesian(B.e, B.n)); + points.push(...semicircle(center1, B, C, clockwise)); + points.push(toCartesian(C.e, C.n)); + points.push(toCartesian(D_pt.e, D_pt.n)); + points.push(...semicircle(center2, D_pt, A, clockwise)); + points.push(Cesium.Cartesian3.clone(points[0])); + return points; + }, + + /** 兼容旧调用:委托给 calculateLoiterPath */ + getRaceTrackLoopPositions(centerCartesian, directionRad, edgeLengthM, arcRadiusM, clockwise, numPointsPerArc) { + return this.calculateLoiterPath(centerCartesian, directionRad, edgeLengthM, arcRadiusM, clockwise, numPointsPerArc); + }, + + /** + * 跑道弧上切线出口点:从跑道飞往下一航点时,弧上与下一航点连成切线的点(航线=弧上该点→下一航点)。 + * 与圆轨迹一致:当前盘旋点与下一航点之间的航线为弧上一点与下一航点之间形成的切线。 + */ + getRaceTrackTangentExitPoint(centerCartesian, nextPointCartesian, directionRad, edgeLengthM, arcRadiusM, clockwise) { + if (!centerCartesian || !nextPointCartesian || edgeLengthM <= 0 || arcRadiusM <= 0) return Cesium.Cartesian3.clone(centerCartesian); + const R = Math.min(Math.max(Number(arcRadiusM), 1), edgeLengthM / 2); + const dir = Number.isFinite(directionRad) ? directionRad : 0; + const L = edgeLengthM; + 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 toCartesian = (eastM, northM) => + Cesium.Cartesian3.add( + Cesium.Cartesian3.add(Cesium.Cartesian3.clone(centerCartesian), Cesium.Cartesian3.multiplyByScalar(east, eastM, new Cesium.Cartesian3()), new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(north, northM, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + const dirE = Math.sin(dir); + const dirN = Math.cos(dir); + const rightE = Math.cos(dir); + const rightN = -Math.sin(dir); + const side = clockwise ? 1 : -1; + const offsetE = side * rightE; + const offsetN = side * rightN; + const B = { e: L * dirE, n: L * dirN }; + const A = Cesium.Cartesian3.clone(centerCartesian); + const arc1Center = toCartesian(B.e + R * offsetE, B.n + R * offsetN); + const arc2Center = toCartesian(R * offsetE, R * offsetN); + const B_cart = toCartesian(B.e, B.n); + const tangentFromCircle = (circleCenter, oppositePoint, revDir) => { + const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, circleCenter, new Cesium.Cartesian3()); + const d = Cesium.Cartesian3.magnitude(toNext); + if (d < 1e-6) return null; + if (R >= d) return null; + const e = Cesium.Cartesian3.dot(toNext, east); + const n = Cesium.Cartesian3.dot(toNext, north); + const theta = Math.atan2(e, n); + const alpha = Math.acos(Math.min(1, R / d)); + const pointAt = (angle) => { + const off = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * R, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * R, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(circleCenter, off, new Cesium.Cartesian3()); + }; + const p1 = pointAt(theta + alpha); + const p2 = pointAt(theta - alpha); + const onArc = (p) => { + const toP = Cesium.Cartesian3.subtract(p, circleCenter, new Cesium.Cartesian3()); + const toOpp = Cesium.Cartesian3.subtract(oppositePoint, circleCenter, new Cesium.Cartesian3()); + return Cesium.Cartesian3.dot(toP, toOpp) <= 0; + }; + const exitDir = (p) => Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(nextPointCartesian, p, new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const tangentAt = (p) => { + const toP = Cesium.Cartesian3.subtract(p, circleCenter, new Cesium.Cartesian3()); + const rn = Cesium.Cartesian3.dot(toP, north); + const re = Cesium.Cartesian3.dot(toP, east); + const tn = revDir ? re : -re; + const te = revDir ? -rn : rn; + return Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, tn, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, te, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ), new Cesium.Cartesian3()); + }; + let best = null; + let bestDot = -2; + [p1, p2].forEach((p) => { + if (!onArc(p)) return; + const ex = exitDir(p); + const ta = tangentAt(p); + const dot = Cesium.Cartesian3.dot(ex, ta); + if (dot > bestDot) { bestDot = dot; best = p; } + }); + return best; + }; + // 弧的遍历方向与跑道一致:顺时针=角度递减,切线方向 revDir=clockwise + const t1 = tangentFromCircle(arc1Center, A, clockwise); + const t2 = tangentFromCircle(arc2Center, B_cart, clockwise); + let candidates = [t1, t2].filter(Boolean); + if (candidates.length === 0) { + const fallback = (circleCenter, revDir) => { + const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, circleCenter, new Cesium.Cartesian3()); + const d = Cesium.Cartesian3.magnitude(toNext); + if (d < 1e-6 || R >= d) return null; + const e = Cesium.Cartesian3.dot(toNext, east); + const n = Cesium.Cartesian3.dot(toNext, north); + const theta = Math.atan2(e, n); + const alpha = Math.acos(Math.min(1, R / d)); + const pointAt = (angle) => { + const off = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * R, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * R, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(circleCenter, off, new Cesium.Cartesian3()); + }; + const p1 = pointAt(theta + alpha); + const p2 = pointAt(theta - alpha); + const exitDir = (p) => Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(nextPointCartesian, p, new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const tangentAt = (p) => { + const toP = Cesium.Cartesian3.subtract(p, circleCenter, new Cesium.Cartesian3()); + const rn = Cesium.Cartesian3.dot(toP, north); + const re = Cesium.Cartesian3.dot(toP, east); + const tn = revDir ? re : -re; + const te = revDir ? -rn : rn; + return Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, tn, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, te, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ), new Cesium.Cartesian3()); + }; + const d1 = Cesium.Cartesian3.dot(exitDir(p1), tangentAt(p1)); + const d2 = Cesium.Cartesian3.dot(exitDir(p2), tangentAt(p2)); + return d1 >= d2 ? p1 : p2; + }; + const f1 = fallback(arc1Center, clockwise); + const f2 = fallback(arc2Center, clockwise); + candidates = [f1, f2].filter(Boolean); + } + if (candidates.length === 0) return Cesium.Cartesian3.clone(A); + if (candidates.length === 1) return Cesium.Cartesian3.clone(candidates[0]); + const dist0 = Cesium.Cartesian3.distance(candidates[0], nextPointCartesian); + const dist1 = Cesium.Cartesian3.distance(candidates[1], nextPointCartesian); + return Cesium.Cartesian3.clone(dist0 <= dist1 ? candidates[0] : candidates[1]); + }, + + /** + * 跑道弧上切线入口点:从上一航点飞向跑道时,弧上与上一航点连成切线的点(航线=上一航点→弧上该点)。 + */ + getRaceTrackTangentEntryPoint(centerCartesian, prevPointCartesian, directionRad, edgeLengthM, arcRadiusM, clockwise) { + if (!centerCartesian || !prevPointCartesian || edgeLengthM <= 0 || arcRadiusM <= 0) return Cesium.Cartesian3.clone(centerCartesian); + const R = Math.min(Math.max(Number(arcRadiusM), 1), edgeLengthM / 2); + const dir = Number.isFinite(directionRad) ? directionRad : 0; + const L = edgeLengthM; + 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 toCartesian = (eastM, northM) => + Cesium.Cartesian3.add( + Cesium.Cartesian3.add(Cesium.Cartesian3.clone(centerCartesian), Cesium.Cartesian3.multiplyByScalar(east, eastM, new Cesium.Cartesian3()), new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(north, northM, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + const dirE = Math.sin(dir); + const dirN = Math.cos(dir); + const rightE = Math.cos(dir); + const rightN = -Math.sin(dir); + const side = clockwise ? 1 : -1; + const offsetE = side * rightE; + const offsetN = side * rightN; + const B = { e: L * dirE, n: L * dirN }; + const A = Cesium.Cartesian3.clone(centerCartesian); + const arc1Center = toCartesian(B.e + R * offsetE, B.n + R * offsetN); + const arc2Center = toCartesian(R * offsetE, R * offsetN); + const B_cart = toCartesian(B.e, B.n); + const entryFromCircle = (circleCenter, oppositePoint, revDir) => { + const toPrev = Cesium.Cartesian3.subtract(prevPointCartesian, circleCenter, new Cesium.Cartesian3()); + const d = Cesium.Cartesian3.magnitude(toPrev); + if (d < 1e-6) return null; + if (R >= d) return null; + 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, R / d)); + const pointAt = (angle) => { + const off = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * R, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * R, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(circleCenter, off, new Cesium.Cartesian3()); + }; + const p1 = pointAt(theta + alpha); + const p2 = pointAt(theta - alpha); + const onArc = (p) => { + const toP = Cesium.Cartesian3.subtract(p, circleCenter, new Cesium.Cartesian3()); + const toOpp = Cesium.Cartesian3.subtract(oppositePoint, circleCenter, new Cesium.Cartesian3()); + return Cesium.Cartesian3.dot(toP, toOpp) <= 0; + }; + const approachDir = (p) => Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(p, prevPointCartesian, new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const tangentAt = (p) => { + const toP = Cesium.Cartesian3.subtract(p, circleCenter, new Cesium.Cartesian3()); + const rn = Cesium.Cartesian3.dot(toP, north); + const re = Cesium.Cartesian3.dot(toP, east); + const tn = revDir ? re : -re; + const te = revDir ? -rn : rn; + return Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, tn, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, te, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ), new Cesium.Cartesian3()); + }; + let best = null; + let bestDot = -2; + [p1, p2].forEach((p) => { + if (!onArc(p)) return; + const ap = approachDir(p); + const ta = tangentAt(p); + const dot = Cesium.Cartesian3.dot(ap, ta); + if (dot > bestDot) { bestDot = dot; best = p; } + }); + return best; + }; + // 与跑道弧遍历一致:顺时针=角度递减,切线方向 revDir=clockwise + const t1 = entryFromCircle(arc1Center, A, clockwise); + const t2 = entryFromCircle(arc2Center, B_cart, clockwise); + const candidates = [t1, t2].filter(Boolean); + if (candidates.length === 0) return Cesium.Cartesian3.clone(A); + if (candidates.length === 1) return Cesium.Cartesian3.clone(candidates[0]); + const dist0 = Cesium.Cartesian3.distance(candidates[0], prevPointCartesian); + const dist1 = Cesium.Cartesian3.distance(candidates[1], prevPointCartesian); + return Cesium.Cartesian3.clone(dist0 <= dist1 ? candidates[0] : candidates[1]); + }, + + /** 圆上整圈(360°)采样,从 startAngleRad 起按顺时针/逆时针,用于盘旋段渲染为整圆。顺时针=12→3→6→9,逆时针=12→9→6→3 */ getCircleFullCircle(centerCartesian, radiusMeters, startAngleRad, clockwise, numPoints) { 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 sign = clockwise ? 1 : -1; const points = []; for (let i = 0; i <= numPoints; i++) { const t = i / numPoints; @@ -2715,7 +3118,7 @@ export default { let entryAngle = toAngle(entryCartesian); let exitAngle = toAngle(exitCartesian); let diff = exitAngle - entryAngle; - const sign = clockwise ? -1 : 1; + const sign = clockwise ? 1 : -1; if (sign * diff <= 0) diff += sign * 2 * Math.PI; const points = []; for (let i = 0; i <= numPoints; i++) { @@ -2776,6 +3179,7 @@ export default { 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); + /* 长弧必须沿 -t 方向才能从 entry 到 exit,故此处保持 clockwise ? -1 : 1;世界方向由 getEllipseFullCircle 等处的 sign 统一 */ 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); @@ -2803,7 +3207,7 @@ export default { 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; + const sign = clockwise ? 1 : -1; if (sign * (diff - Math.PI) > 0) diff -= 2 * Math.PI; const n = Math.max(2, numPoints || 48); const points = []; @@ -2814,10 +3218,10 @@ export default { return points; }, - /** 椭圆整圈(用于弧长计算等),从 startLocalAngle 起按顺时针/逆时针 */ + /** 椭圆整圈(用于弧长计算等),从 startLocalAngle 起按顺时针/逆时针。顺时针=12→3→6→9,逆时针=12→9→6→3 */ getEllipseFullCircle(centerCartesian, semiMajorM, semiMinorM, headingRad, startLocalAngle, clockwise, numPoints) { const t0 = this.polarToEllipseParam(startLocalAngle, semiMajorM, semiMinorM); - const sign = clockwise ? -1 : 1; + const sign = clockwise ? 1 : -1; const n = Math.max(2, numPoints || 64); const points = []; for (let i = 0; i <= n; i++) { @@ -2855,14 +3259,14 @@ export default { return arc; }, - /** 圆:中心( Cartesian3 )、半径(米)、顺时针、采样数 → 世界坐标点数组(从进入点沿圆到出口点需外部指定起止角或由 entry/exit 截取) */ + /** 圆:中心( Cartesian3 )、半径(米)、顺时针、采样数 → 世界坐标点数组。顺时针=12→3→6→9,逆时针=12→9→6→3 */ computeCirclePositions(centerCartesian, radiusMeters, clockwise, numPoints) { if (!this.viewer || !centerCartesian || radiusMeters <= 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 points = []; - const sign = clockwise ? -1 : 1; + const sign = clockwise ? 1 : -1; for (let i = 0; i <= numPoints; i++) { const t = i / numPoints; const angle = sign * t * 2 * Math.PI; @@ -2876,10 +3280,10 @@ export default { return points; }, - /** 椭圆:中心、半长轴/半短轴(米)、长轴方位(弧度)、顺时针、采样数 → 世界坐标点数组(与 ellipsePointAtParam 同一约定) */ + /** 椭圆:中心、半长轴/半短轴(米)、长轴方位(弧度)、顺时针、采样数 → 世界坐标点数组。顺时针=12→3→6→9,逆时针=12→9→6→3 */ computeEllipsePositions(centerCartesian, semiMajorM, semiMinorM, headingRad, clockwise, numPoints) { if (!this.viewer || !centerCartesian || semiMajorM <= 0 || semiMinorM <= 0) return []; - const sign = clockwise ? -1 : 1; + const sign = clockwise ? 1 : -1; const points = []; for (let i = 0; i <= numPoints; i++) { const t = sign * (i / numPoints) * 2 * Math.PI; @@ -2899,7 +3303,7 @@ export default { return Cesium.Cartesian3.add(prevPointCartesian, Cesium.Cartesian3.multiplyByScalar(unit, dist - radiusMeters, new Cesium.Cartesian3()), new Cesium.Cartesian3()); }, - /** 圆上切线进入点:从 prev 飞向圆时在圆上的切点(与出口切点对称,选使进入后沿顺时针/逆时针顺滑的那一侧) */ + /** 圆上切线进入点:从 prev 飞向圆时在圆上的切点。选使「接近方向」与「圆上飞行方向」最一致的那侧,进入时转弯最小、顺滑(见图三:顺着大方向盘旋) */ getCircleTangentEntryPoint(centerCartesian, prevPointCartesian, radiusMeters, clockwise) { const toPrev = Cesium.Cartesian3.subtract(prevPointCartesian, centerCartesian, new Cesium.Cartesian3()); const d = Cesium.Cartesian3.magnitude(toPrev); @@ -2912,19 +3316,41 @@ export default { 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()); + const angle1 = theta - alpha; + const angle2 = theta + alpha; + const pointAt = (angle) => { + const off = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * radiusMeters, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * radiusMeters, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(centerCartesian, off, new Cesium.Cartesian3()); + }; + const p1 = pointAt(angle1); + const p2 = pointAt(angle2); + 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 tangentAt = (angle) => { + const tn = clockwise ? -Math.sin(angle) : Math.sin(angle); + const te = clockwise ? Math.cos(angle) : -Math.cos(angle); + return Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, tn, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, te, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + }; + const t1 = tangentAt(angle1); + const t2 = tangentAt(angle2); + const dot1 = Cesium.Cartesian3.dot(approach1, t1); + const dot2 = Cesium.Cartesian3.dot(approach2, t2); + return dot1 >= dot2 ? p1 : p2; }, /** * 根据盘旋总距离反算圆半径,使从进入点到切点出口的弧长 = totalHoldDistM(整圈 + entry→exit 弧), - * 从而在 k+10 时刻飞机自然落在切点,无需强制位移。返回值不小于 MIN_HOLD_RADIUS_M。 + * 从而在相对K时时刻飞机自然落在切点。使用与 getRoutePath 相同的 circleHoldArcLengthM 度量,保证一致性。 * @param centerCartesian - 盘旋中心 * @param prevPointCartesian - 上一航点 * @param nextPointCartesian - 下一航点 @@ -2934,47 +3360,24 @@ export default { */ 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); + if (Rmax < 1) return null; + const toleranceM = 0.1; + let Rlo = 10; + let Rhi = Rmax; + for (let iter = 0; iter < 80; iter++) { + const R = (Rlo + Rhi) / 2; + const len = this.circleHoldArcLengthM(centerCartesian, prevPointCartesian, nextPointCartesian || centerCartesian, R, clockwise); + if (!Number.isFinite(len) || len <= 0) return null; + if (Math.abs(len - totalHoldDistM) < toleranceM) return Math.min(Rmax, R); + if (len < totalHoldDistM) Rlo = R; else Rhi = R; + if (Rhi - Rlo < 0.01) break; + } + const R = (Rlo + Rhi) / 2; + return Math.min(Rmax, R); }, /** @@ -2995,8 +3398,45 @@ export default { if (this.viewer && this.viewer.scene) this.viewer.scene.requestRender(); }, - /** 椭圆盘旋总弧长(切线入口 → 整椭圆 → 弧至切线出口),用于反算尺寸 */ + /** 与 childRoom.segmentDistance 一致:经纬度+高度求距离(米),保证弧长与时间线 pathSliceDistance 同度量 */ + segmentDistanceFromCartesian(cartesian1, cartesian2) { + const c1 = Cesium.Cartographic.fromCartesian(cartesian1); + const c2 = Cesium.Cartographic.fromCartesian(cartesian2); + const lat1 = c1.latitude; + const lat2 = c2.latitude; + const dlat = c2.latitude - c1.latitude; + const dlng = c2.longitude - c1.longitude; + const R = 6371000; + const a = Math.sin(dlat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dlng / 2) ** 2; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const horizontal = R * c; + const dalt = (c2.height || 0) - (c1.height || 0); + return Math.sqrt(horizontal * horizontal + dalt * dalt); + }, + + /** 圆形盘旋总弧长(切线入口 → 整圆一圈 → 弧至切线出口),与 getRoutePath 构建的路径同度量(弦长累加) */ + circleHoldArcLengthM(centerCartesian, prevPointCartesian, nextPointCartesian, radiusMeters, clockwise) { + const CIRCLE_POINTS = 64; + const ARC_POINTS = 24; + const entry = this.getCircleTangentEntryPoint(centerCartesian, prevPointCartesian, radiusMeters, clockwise); + const exit = this.getCircleTangentExitPoint(centerCartesian, nextPointCartesian || centerCartesian, radiusMeters, 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 entryAngle = Math.atan2(Cesium.Cartesian3.dot(toEntry, east), Cesium.Cartesian3.dot(toEntry, north)); + const circlePts = this.getCircleFullCircle(centerCartesian, radiusMeters, entryAngle, clockwise, CIRCLE_POINTS); + const arcToExit = this.getCircleArcEntryToExit(centerCartesian, radiusMeters, entry, exit, clockwise, ARC_POINTS); + const pts = circlePts && circlePts.length ? (arcToExit && arcToExit.length > 1 ? [...circlePts, ...arcToExit.slice(1)] : circlePts) : [entry, exit]; + let len = 0; + for (let i = 1; i < pts.length; i++) len += this.segmentDistanceFromCartesian(pts[i - 1], pts[i]); + return len; + }, + + /** 椭圆盘旋总弧长(切线入口 → 整椭圆一圈 → 弧至切线出口),用于反算尺寸;与 getRoutePath 构建的路径同度量(弦长累加,采样数一致) */ ellipseHoldArcLengthM(centerCartesian, prevPointCartesian, nextPointCartesian, semiMajorM, semiMinorM, headingRad, clockwise) { + const ELLIPSE_CIRCLE_POINTS = 128; + const ELLIPSE_ARC_POINTS = 120; 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); @@ -3007,46 +3447,45 @@ export default { 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 fullCircle = this.getEllipseFullCircle(centerCartesian, semiMajorM, semiMinorM, headingRad, entryLocalAngle, clockwise, ELLIPSE_CIRCLE_POINTS); + const arcToExit = this.getEllipseArcEntryToExit(centerCartesian, semiMajorM, semiMinorM, headingRad, entry, exit, clockwise, ELLIPSE_ARC_POINTS, true); 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]); + for (let i = 1; i < pts.length; i++) len += this.segmentDistanceFromCartesian(pts[i - 1], pts[i]); return len; }, /** * 根据盘旋总距离反算椭圆半轴,使切线入口→整椭圆→切线出口的弧长 = totalHoldDistM(椭圆保持长轴方位与长短轴比)。 - * 返回值不小于 MIN_ELLIPSE_SEMI_MAJOR_M / MIN_ELLIPSE_SEMI_MINOR_M,保证飞机能盘旋开。 - * @param headingDeg - 长轴方位(度) + * 返回计算所得半轴(可能小于 MIN,由调用方与最小值比较并报冲突)。 + * @param headingDeg - 长轴方位(度),未传则用 0 * @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 toleranceM = 0.1; + for (let iter = 0; iter < 80; 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 (!Number.isFinite(len) || len <= 0) return null; + if (Math.abs(len - totalHoldDistM) < toleranceM) return { semiMajor: smj, semiMinor: 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) }; + return { semiMajor: k * a0, semiMinor: k * b0 }; }, - /** 圆上切线出口点:从圆飞往 next 时在圆上的切点(选顺时针/逆时针中朝向 next 的那一侧) */ + /** 圆上切线出口点:从圆飞往 next 时在圆上的切点。选使「飞出方向」与「圆上飞行方向」最一致的那侧,离开时转弯最小、顺滑 */ getCircleTangentExitPoint(centerCartesian, nextPointCartesian, radiusMeters, clockwise) { const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, centerCartesian, new Cesium.Cartesian3()); const d = Cesium.Cartesian3.magnitude(toNext); @@ -3058,17 +3497,39 @@ export default { const n = Cesium.Cartesian3.dot(toNext, north); const theta = Math.atan2(e, n); const alpha = Math.acos(Math.min(1, radiusMeters / d)); - const sign = clockwise ? 1 : -1; - const exitAngle = theta + sign * alpha; - const offset = Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, Math.cos(exitAngle) * radiusMeters, new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, Math.sin(exitAngle) * radiusMeters, new Cesium.Cartesian3()), - new Cesium.Cartesian3() - ); - return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); + const angle1 = theta + alpha; + const angle2 = theta - alpha; + const pointAt = (angle) => { + const off = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * radiusMeters, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * radiusMeters, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(centerCartesian, off, new Cesium.Cartesian3()); + }; + const p1 = pointAt(angle1); + const p2 = pointAt(angle2); + 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 tangentAt = (angle) => { + const tn = clockwise ? -Math.sin(angle) : Math.sin(angle); + const te = clockwise ? Math.cos(angle) : -Math.cos(angle); + return Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, tn, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, te, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + }; + const t1 = tangentAt(angle1); + const t2 = tangentAt(angle2); + const dot1 = Cesium.Cartesian3.dot(exitDir1, t1); + const dot2 = Cesium.Cartesian3.dot(exitDir2, t2); + return dot1 >= dot2 ? p1 : p2; }, - /** 椭圆在参数 t 处的单位切向量(世界坐标,t 增加方向);clockwise 为 true 时返回飞行方向(t 减少) */ + /** 椭圆在参数 t 处的单位切向量(世界坐标);clockwise 为 true 时返回飞行方向(顺时针=12→3→6→9) */ ellipseTangentAtParam(centerCartesian, semiMajorM, semiMinorM, headingRad, t, clockwise) { const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); @@ -3080,7 +3541,7 @@ export default { 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; + 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()), @@ -3234,6 +3695,7 @@ export default { const segmentEndIndices = []; const holdArcRanges = {}; let lastPos = originalPositions[0]; + let prevWasHold = false; for (let i = 1; i < waypoints.length; i++) { const currPos = originalPositions[i]; const wp = waypoints[i]; @@ -3241,67 +3703,79 @@ export default { if (this.isHoldWaypoint(wp)) { const params = this.parseHoldParams(wp); 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 pt = wp.pointType || wp.point_type; + const useCircle = pt === 'hold_circle'; const clockwise = params && params.clockwise !== false; - 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; + let holdPositions; if (useCircle) { + const overrideRadius = holdRadiusByLegIndex[legIndex]; + const radius = (overrideRadius != null && Number.isFinite(overrideRadius)) + ? overrideRadius + : (params && params.radius != null ? params.radius : 500); + const entry = this.getCircleTangentEntryPoint(currPos, lastPos, radius, clockwise); + const exit = this.getCircleTangentExitPoint(currPos, nextPos || currPos, radius, clockwise); 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); + const toP = Cesium.Cartesian3.subtract(entry, currPos, new Cesium.Cartesian3()); + const entryAngle = Math.atan2(Cesium.Cartesian3.dot(toP, east), Cesium.Cartesian3.dot(toP, north)); + const circlePts = this.getCircleFullCircle(currPos, radius, entryAngle, clockwise, 64); + const arcToExit = this.getCircleArcEntryToExit(currPos, radius, entry, exit, clockwise, 24); + const arcPoints = circlePts && circlePts.length + ? (arcToExit && arcToExit.length > 1 ? [...circlePts, ...arcToExit.slice(1)] : circlePts) + : (arcToExit && arcToExit.length ? arcToExit : [entry, exit]); + holdPositions = arcPoints && arcPoints.length ? arcPoints : [entry, exit]; + lastPos = exit; } else { - 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)]; + const edgeLengthM = Math.max(1000, params && params.edgeLength != null ? params.edgeLength : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M); + const arcRadiusM = this.getWaypointRadius({ ...wp, turnAngle: this.getEffectiveTurnAngle(wp, i, waypoints.length) }) || 500; + const directionRad = this.getRaceTrackDirectionRad( + currPos, + lastPos, + nextPos, + params && params.headingDeg != null ? params.headingDeg : 0 + ); + holdPositions = this.getRaceTrackLoopPositions(currPos, directionRad, edgeLengthM, arcRadiusM, clockwise, 24); + if (nextPos) { + lastPos = this.getRaceTrackTangentExitPoint(currPos, nextPos, directionRad, edgeLengthM, arcRadiusM, clockwise); + } else { + lastPos = holdPositions.length ? holdPositions[holdPositions.length - 1] : currPos; + } + } 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; - lastPos = exit; + prevWasHold = true; } else { let nextLogical = nextPos; if (nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1])) { - const holdParams = this.parseHoldParams(waypoints[i + 1]); - 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)); + const nextWp = waypoints[i + 1]; + const holdParams = this.parseHoldParams(nextWp); + const nextHoldIsCircle = (nextWp.pointType || nextWp.point_type) === 'hold_circle'; + if (nextHoldIsCircle) { + const holdClock = holdParams && holdParams.clockwise !== false; + const holdR = (holdRadiusByLegIndex && holdRadiusByLegIndex[i] != null) ? holdRadiusByLegIndex[i] : (holdParams && holdParams.radius != null ? 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); + } else { + const nextNext = i + 2 < waypoints.length ? originalPositions[i + 2] : null; + const nextDir = this.getRaceTrackDirectionRad(originalPositions[i + 1], currPos, nextNext, holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0); + const nextEdge = Math.max(1000, holdParams && holdParams.edgeLength != null ? holdParams.edgeLength : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M); + const nextArcR = this.getWaypointRadius({ ...nextWp, turnAngle: this.getEffectiveTurnAngle(nextWp, i + 1, waypoints.length) }) || 500; + nextLogical = this.getRaceTrackTangentEntryPoint(originalPositions[i + 1], lastPos, nextDir, nextEdge, nextArcR, holdParams && holdParams.clockwise !== false); } } const effectiveAngle = this.getEffectiveTurnAngle(wp, i, waypoints.length); const radius = this.getWaypointRadius({ ...wp, turnAngle: effectiveAngle }); const nextIsHold = nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1]); if (i < waypoints.length - 1 && nextLogical) { - if (nextIsHold) { + if (prevWasHold) { + // 上一航点为盘旋时:航线严格为「盘旋弧上切点→下一航点」的切线,保证平台移动流畅 + path.push(toLngLatAlt(lastPos)); + path.push(toLngLatAlt(nextLogical)); + lastPos = nextLogical; + prevWasHold = false; + } else if (nextIsHold) { path.push(toLngLatAlt(nextLogical)); lastPos = nextLogical; } else if (radius > 0) { @@ -3316,6 +3790,7 @@ export default { path.push(toLngLatAlt(currPos)); lastPos = currPos; } + prevWasHold = false; segmentEndIndices[i - 1] = path.length - 1; } } diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index 06df25f..e89aeba 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -237,7 +237,19 @@ {{ currentTime }} -
+
+
+ {{ timelineHoverTime }} +
-
- - {{ playbackSpeed }}x - -
+ + {{ playbackSpeed }}x + + {{ s }}x + +
@@ -287,14 +286,8 @@ @@ -723,12 +716,18 @@ export default { deductionEarlyArrivalByRoute: {}, // routeId -> earlyArrivalLegs showAddHoldDialog: false, addHoldContext: null, // { routeId, routeName, legIndex, fromName, toName } - addHoldForm: { holdType: 'hold_circle', radius: 15000, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: null }, + addHoldForm: { holdType: 'hold_circle', radius: 15000, edgeLengthKm: 20, clockwise: true, startTime: '', startTimeMinutes: null }, missionDrawingActive: false, missionDrawingPointsCount: 0, isPlaying: false, playbackSpeed: 1, + speedOptions: [64, 32, 16, 8, 4, 2, 1], playbackInterval: null, + /** 播放时上一帧时间戳(毫秒),用于按真实经过时间推进,避免 setInterval 不准导致时间轴变慢 */ + _playbackLastTickTime: null, + timelineHoverTime: '', + timelineHoverVisible: false, + timelineHoverPercent: 0, /** 导弹从 Redis 加载过的房间+航线组合 key,避免重复加载 */ _missilesLoadKey: null, @@ -1193,14 +1192,15 @@ export default { let pointType; let holdParams; let turnAngle; + const preserveTurnAngle = () => isFirstOrLast ? 0 : (wp.turnAngle != null && wp.turnAngle !== '' ? Number(wp.turnAngle) : 0); if (isHold) { pointType = 'normal'; holdParams = null; - turnAngle = isFirstOrLast ? 0 : (Number(wp.turnAngle) || 45); + turnAngle = preserveTurnAngle(); } else { pointType = 'hold_circle'; holdParams = JSON.stringify({ radius: 15000, clockwise: true }); - turnAngle = 0; + turnAngle = preserveTurnAngle(); } try { const payload = { @@ -1247,6 +1247,10 @@ export default { if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data); } catch (_) {} } + const { minMinutes, maxMinutes } = this.getDeductionTimeRange(); + if (r.waypoints.some(wp => this.isHoldWaypoint(wp))) { + this.getPositionAtMinutesFromK(r.waypoints, minMinutes, minMinutes, maxMinutes, routeId); + } this.$refs.cesiumMap.removeRouteById(routeId); this.$refs.cesiumMap.renderRouteWaypoints(r.waypoints, routeId, r.platformId, r.platform, this.parseRouteStyle(r.attributes)); this.$nextTick(() => this.updateDeductionPositions()); @@ -1337,6 +1341,10 @@ export default { if (wp.holdParams != null) payload.holdParams = wp.holdParams; if (wp.labelFontSize != null) payload.labelFontSize = wp.labelFontSize; if (wp.labelColor != null) payload.labelColor = wp.labelColor; + if (wp.segmentMode != null) payload.segmentMode = wp.segmentMode; + if (wp.color != null) payload.color = wp.color; + if (wp.pixelSize != null) payload.pixelSize = wp.pixelSize; + if (wp.outlineColor != null) payload.outlineColor = wp.outlineColor; try { const response = await updateWaypoints(payload); if (response.code === 200) { @@ -1347,7 +1355,96 @@ export default { const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === dbId); if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, merged); } + // 定速/定时:根据拖拽后的新位置重算相对K时或上一航点速度 const routeForPlatform = this.routes.find(r => r.id === routeId) || route; + if (idx > 0) { + const prev = waypoints[idx - 1]; + const distM = this.segmentDistance( + { lat: prev.lat, lng: prev.lng, alt: prev.alt }, + { lat: merged.lat, lng: merged.lng, alt: merged.alt } + ); + const prevMinutes = this.waypointStartTimeToMinutesDecimal(prev.startTime); + if (prev.segmentMode === 'fixed_speed') { + const speedKmh = Number(prev.segmentTargetSpeed ?? prev.speed) || 800; + const newMinutesFromK = prevMinutes + (distM / 1000) / speedKmh * 60; + const newStartTime = this.minutesToStartTimeWithSeconds(newMinutesFromK); + const startPayload = { ...merged, startTime: newStartTime }; + if (merged.segmentMode != null) startPayload.segmentMode = merged.segmentMode; + try { + const r2 = await updateWaypoints(startPayload); + if (r2.code === 200) { + Object.assign(merged, { startTime: newStartTime }); + if (idx !== -1) waypoints.splice(idx, 1, merged); + if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) { + const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === dbId); + if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, merged); + } + } + } catch (e) { + console.warn('定速重算相对K时失败', e); + } + } else if (merged.segmentMode === 'fixed_time') { + const currMinutes = (merged.segmentTargetMinutes != null && merged.segmentTargetMinutes !== '') ? Number(merged.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(merged.startTime); + const deltaMin = currMinutes - prevMinutes; + if (deltaMin > 0.001) { + const newSpeedKmh = (distM / 1000) / (deltaMin / 60); + const speedVal = Math.round(newSpeedKmh * 10) / 10; + const speedPayload = { ...prev, speed: speedVal }; + if (prev.segmentMode != null) speedPayload.segmentMode = prev.segmentMode; + try { + const r2 = await updateWaypoints(speedPayload); + if (r2.code === 200) { + Object.assign(prev, { speed: speedVal }); + const prevIdx = idx - 1; + waypoints.splice(prevIdx, 1, prev); + if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) { + const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === prev.id); + if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, prev); + } + } + } catch (e) { + console.warn('定时重算上一航点速度失败', e); + } + } + } + } + // 下一航点是定时点时:重算本航点(被拖动的)速度,使平台能在下一航点的 K 时到达 + if (idx >= 0 && idx < waypoints.length - 1) { + const next = waypoints[idx + 1]; + if (next.segmentMode === 'fixed_time') { + const distToNextM = this.segmentDistance( + { lat: merged.lat, lng: merged.lng, alt: merged.alt }, + { lat: next.lat, lng: next.lng, alt: next.alt } + ); + const currMinutes = this.waypointStartTimeToMinutesDecimal(merged.startTime); + const nextMinutes = (next.segmentTargetMinutes != null && next.segmentTargetMinutes !== '') ? Number(next.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(next.startTime); + const deltaMin = nextMinutes - currMinutes; + if (deltaMin > 0.001) { + const newSpeedKmh = (distToNextM / 1000) / (deltaMin / 60); + const speedVal = Math.round(newSpeedKmh * 10) / 10; + const currPayload = { ...merged, speed: speedVal }; + if (merged.segmentMode != null) currPayload.segmentMode = merged.segmentMode; + if (merged.labelFontSize != null) currPayload.labelFontSize = merged.labelFontSize; + if (merged.labelColor != null) currPayload.labelColor = merged.labelColor; + if (merged.color != null) currPayload.color = merged.color; + if (merged.pixelSize != null) currPayload.pixelSize = merged.pixelSize; + if (merged.outlineColor != null) currPayload.outlineColor = merged.outlineColor; + try { + const r2 = await updateWaypoints(currPayload); + if (r2.code === 200) { + Object.assign(merged, { speed: speedVal }); + waypoints.splice(idx, 1, merged); + if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) { + const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === dbId); + if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, merged); + } + } + } catch (e) { + console.warn('下一航点为定时点重算本航点速度失败', e); + } + } + } + } if (this.$refs.cesiumMap) { const roomId = this.currentRoomId; if (roomId && routeForPlatform.platformId) { @@ -1658,6 +1755,10 @@ export default { if (this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap && waypoints.length > 0) { const r = this.routes.find(rr => rr.id === routeId); if (r) { + if (waypoints.some(wp => this.isHoldWaypoint(wp))) { + const { minMinutes, maxMinutes } = this.getDeductionTimeRange(); + this.getPositionAtMinutesFromK(waypoints, minMinutes, minMinutes, maxMinutes, routeId); + } this.$refs.cesiumMap.removeRouteById(routeId); this.$refs.cesiumMap.renderRouteWaypoints(waypoints, routeId, r.platformId, r.platform, this.parseRouteStyle(r.attributes)); this.$nextTick(() => this.updateDeductionPositions()); @@ -2056,9 +2157,14 @@ export default { } })); this.$nextTick(() => { + const { minMinutes, maxMinutes } = this.getDeductionTimeRange(); this.activeRouteIds.forEach(id => { const route = this.routes.find(r => r.id === id); if (route && route.waypoints && route.waypoints.length > 0 && this.$refs.cesiumMap) { + // 含盘旋航点时先预计算半径/长短轴(根据相对K时与距离),再渲染,使圆/椭圆尺寸正确 + if (route.waypoints.some(wp => this.isHoldWaypoint(wp))) { + this.getPositionAtMinutesFromK(route.waypoints, minMinutes, minMinutes, maxMinutes, id); + } this.$refs.cesiumMap.removeRouteById(id); this.$refs.cesiumMap.renderRouteWaypoints(route.waypoints, id, route.platformId, route.platform, this.parseRouteStyle(route.attributes)); } @@ -2110,7 +2216,7 @@ export default { openAddHoldDuringDrawing() { this.addHoldContext = { mode: 'drawing' }; - this.addHoldForm = { holdType: 'hold_circle', radius: 15000, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 }; + this.addHoldForm = { holdType: 'hold_circle', radius: 15000, edgeLengthKm: 20, clockwise: true, startTime: '', startTimeMinutes: 60 }; this.showAddHoldDialog = true; }, @@ -2281,6 +2387,9 @@ export default { if (updatedWaypoint.holdParams != null) payload.holdParams = updatedWaypoint.holdParams; if (updatedWaypoint.labelFontSize != null) payload.labelFontSize = updatedWaypoint.labelFontSize; if (updatedWaypoint.labelColor != null) payload.labelColor = updatedWaypoint.labelColor; + if (updatedWaypoint.segmentMode != null) payload.segmentMode = updatedWaypoint.segmentMode; + if (updatedWaypoint.segmentTargetMinutes !== undefined) payload.segmentTargetMinutes = updatedWaypoint.segmentTargetMinutes; + if (updatedWaypoint.segmentTargetSpeed !== undefined) payload.segmentTargetSpeed = updatedWaypoint.segmentTargetSpeed; if (updatedWaypoint.pixelSize != null) payload.pixelSize = updatedWaypoint.pixelSize; if (updatedWaypoint.color != null) payload.color = updatedWaypoint.color; if (updatedWaypoint.outlineColor != null) payload.outlineColor = updatedWaypoint.outlineColor; @@ -2298,6 +2407,78 @@ export default { const idxInList = routeInList.waypoints.findIndex(p => p.id === updatedWaypoint.id); if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged); } + // 定时点:根据“本航点相对K时”反算上一航点速度,使平台能在 K+本点时间 到达本点并出发(或提前到达后盘旋至该时刻) + if (merged.segmentMode === 'fixed_time' && index > 0) { + const prev = sd.waypoints[index - 1]; + const distM = this.segmentDistance( + { lat: prev.lat, lng: prev.lng, alt: prev.alt }, + { lat: merged.lat, lng: merged.lng, alt: merged.alt } + ); + const prevMinutes = this.waypointStartTimeToMinutesDecimal(prev.startTime); + const currMinutes = (merged.segmentTargetMinutes != null && merged.segmentTargetMinutes !== '') ? Number(merged.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(merged.startTime); + const deltaMin = currMinutes - prevMinutes; + if (deltaMin > 0.001) { + const newSpeedKmh = (distM / 1000) / (deltaMin / 60); + const speedVal = Math.round(newSpeedKmh * 10) / 10; + const prevPayload = { ...prev, speed: speedVal }; + if (prev.segmentMode != null) prevPayload.segmentMode = prev.segmentMode; + if (prev.labelFontSize != null) prevPayload.labelFontSize = prev.labelFontSize; + if (prev.labelColor != null) prevPayload.labelColor = prev.labelColor; + if (prev.color != null) prevPayload.color = prev.color; + if (prev.pixelSize != null) prevPayload.pixelSize = prev.pixelSize; + if (prev.outlineColor != null) prevPayload.outlineColor = prev.outlineColor; + try { + const r2 = await updateWaypoints(prevPayload); + if (r2.code === 200) { + Object.assign(prev, { speed: speedVal }); + sd.waypoints.splice(index - 1, 1, prev); + if (routeInList && routeInList.waypoints) { + const prevIdxInList = routeInList.waypoints.findIndex(p => p.id === prev.id); + if (prevIdxInList !== -1) routeInList.waypoints.splice(prevIdxInList, 1, prev); + } + } + } catch (e) { + console.warn('定时点重算上一航点速度失败', e); + } + } + } + // 下一航点是定时点时:重算本航点速度,使平台能在下一航点的 K 时到达 + if (index < sd.waypoints.length - 1) { + const next = sd.waypoints[index + 1]; + if (next.segmentMode === 'fixed_time') { + const distToNextM = this.segmentDistance( + { lat: merged.lat, lng: merged.lng, alt: merged.alt }, + { lat: next.lat, lng: next.lng, alt: next.alt } + ); + const currMinutes = this.waypointStartTimeToMinutesDecimal(merged.startTime); + const nextMinutes = (next.segmentTargetMinutes != null && next.segmentTargetMinutes !== '') ? Number(next.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(next.startTime); + const deltaMin = nextMinutes - currMinutes; + if (deltaMin > 0.001) { + const newSpeedKmh = (distToNextM / 1000) / (deltaMin / 60); + const speedVal = Math.round(newSpeedKmh * 10) / 10; + const currPayload = { ...merged, speed: speedVal }; + if (merged.segmentMode != null) currPayload.segmentMode = merged.segmentMode; + if (merged.labelFontSize != null) currPayload.labelFontSize = merged.labelFontSize; + if (merged.labelColor != null) currPayload.labelColor = merged.labelColor; + if (merged.pixelSize != null) currPayload.pixelSize = merged.pixelSize; + if (merged.color != null) currPayload.color = merged.color; + if (merged.outlineColor != null) currPayload.outlineColor = merged.outlineColor; + try { + const r2 = await updateWaypoints(currPayload); + if (r2.code === 200) { + Object.assign(merged, { speed: speedVal }); + sd.waypoints.splice(index, 1, merged); + if (routeInList && routeInList.waypoints) { + const idxInList = routeInList.waypoints.findIndex(p => p.id === merged.id); + if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged); + } + } + } catch (e) { + console.warn('下一航点为定时点重算本航点速度失败', e); + } + } + } + } if (this.$refs.cesiumMap) { if (roomId && sd.platformId) { try { @@ -2316,6 +2497,11 @@ export default { } this.showWaypointDialog = false; this.$message.success('航点信息已持久化至数据库'); + if (this.$refs.cesiumMap && (this.$refs.cesiumMap.setRouteHoldRadii || this.$refs.cesiumMap.setRouteHoldEllipseParams)) { + const rid = sd.id; + if (this.$refs.cesiumMap.setRouteHoldRadii) this.$refs.cesiumMap.setRouteHoldRadii(rid, {}); + if (this.$refs.cesiumMap.setRouteHoldEllipseParams) this.$refs.cesiumMap.setRouteHoldEllipseParams(rid, {}); + } this.$nextTick(() => this.updateDeductionPositions()); this.wsConnection?.sendSyncWaypoints?.(this.selectedRouteDetails.id); // 航点编辑后,根据新位置重算导弹发射位置并更新 Redis @@ -3517,8 +3703,17 @@ export default { if (this.playbackInterval) { clearInterval(this.playbackInterval); } + this._playbackLastTickTime = Date.now(); + // 按真实经过时间推进:1 实机秒 = playbackSpeed 推演秒,避免 setInterval 不准导致“现实两秒对应时间轴一秒” this.playbackInterval = setInterval(() => { - this.timeProgress += this.playbackSpeed * 0.1; + const now = Date.now(); + const elapsedSec = (now - (this._playbackLastTickTime || now)) / 1000; + this._playbackLastTickTime = now; + const { minMinutes, maxMinutes } = this.getDeductionTimeRange(); + const span = Math.max(0, maxMinutes - minMinutes) || 120; + const spanSeconds = span * 60; + // 经过 elapsedSec 实机秒 → 推进 playbackSpeed * elapsedSec 推演秒 → 进度增量 (elapsedSec * playbackSpeed / spanSeconds) * 100 + this.timeProgress += (elapsedSec * this.playbackSpeed / spanSeconds) * 100; if (this.timeProgress >= 100) { this.timeProgress = 0; } @@ -3533,18 +3728,9 @@ export default { } }, - increaseSpeed() { - if (this.playbackSpeed < 25) { - this.playbackSpeed++; - if (this.isPlaying) { - this.startPlayback(); - } - } - }, - - decreaseSpeed() { - if (this.playbackSpeed > 1) { - this.playbackSpeed--; + setPlaybackSpeed(speed) { + if (this.speedOptions.includes(speed)) { + this.playbackSpeed = speed; if (this.isPlaying) { this.startPlayback(); } @@ -3558,10 +3744,11 @@ export default { this.deductionMinutesFromK = currentMinutesFromK; const sign = currentMinutesFromK >= 0 ? '+' : '-'; - const absMin = Math.abs(Math.floor(currentMinutesFromK)); - const hours = Math.floor(absMin / 60); - const minutes = absMin % 60; - this.currentTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`; + const totalSeconds = Math.floor(Math.abs(currentMinutesFromK) * 60); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + this.currentTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; // 右上角作战时间随时与推演时间轴同步(无论是否展示航线) this.combatTime = this.currentTime; this.updateDeductionPositions(); @@ -3676,6 +3863,17 @@ export default { const min = parseInt(m[3], 10); return sign * (h * 60 + min); }, + /** 将 startTime(如 K+00:19:30)转为相对 K 的分钟数(含秒,保留小数) */ + waypointStartTimeToMinutesDecimal(s) { + if (!s || typeof s !== 'string') return 0; + const m = s.match(/K([+-])(\d{2}):(\d{2})(?::(\d{2}))?/); + if (!m) return 0; + const sign = m[1] === '+' ? 1 : -1; + const h = parseInt(m[2], 10); + const min = parseInt(m[3], 10); + const sec = m[4] != null ? parseInt(m[4], 10) : 0; + return sign * (h * 60 + min + sec / 60); + }, /** 将相对 K 的分钟数转为 startTime 字符串(如 K+01:00、K-00:30) */ minutesToStartTime(minutes) { const m = Math.floor(Number(minutes)); @@ -3686,6 +3884,18 @@ export default { const min = abs % 60; return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`; }, + /** 将相对 K 的分钟数(可含小数)转为 startTime,精确到秒(如 K+00:19:30) */ + minutesToStartTimeWithSeconds(minutes) { + const num = Number(minutes); + if (!Number.isFinite(num)) return 'K+00:00:00'; + const sign = num >= 0 ? '+' : '-'; + const abs = Math.abs(num); + const totalSec = Math.round(abs * 60); + const h = Math.floor(totalSec / 3600); + const min = Math.floor((totalSec % 3600) / 60); + const sec = totalSec % 60; + return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; + }, isHoldWaypoint(wp) { const t = (wp && wp.pointType) || (wp && wp.point_type) || 'normal'; @@ -3696,7 +3906,14 @@ export default { if (!raw) return null; try { const p = typeof raw === 'string' ? JSON.parse(raw) : raw; - return { radius: p.radius, semiMajor: p.semiMajor ?? p.semiMajorAxis, semiMinor: p.semiMinor ?? p.semiMinorAxis, headingDeg: p.headingDeg ?? 0, clockwise: p.clockwise !== false }; + return { + radius: p.radius, + semiMajor: p.semiMajor ?? p.semiMajorAxis, + semiMinor: p.semiMinor ?? p.semiMinorAxis, + headingDeg: p.headingDeg ?? 0, + clockwise: p.clockwise !== false, + edgeLength: p.edgeLength != null ? Number(p.edgeLength) : 20000 + }; } catch (e) { return null; } @@ -3799,25 +4016,44 @@ export default { const exitIdx = segmentEndIndices[i]; const toNextSlice = path.slice(exitIdx, (segmentEndIndices[i + 1] != null ? segmentEndIndices[i + 1] : path.length - 1) + 1); const distToEntry = this.pathSliceDistance(toEntrySlice); - const speedKmh = points[i].speed || 800; - const travelToEntryMin = (distToEntry / 1000) * (60 / speedKmh); - const arrivalEntry = effectiveTime[i] + travelToEntryMin; + const holdWpForSegment = waypoints[i + 1]; + const segTarget = holdWpForSegment && (holdWpForSegment.segmentTargetMinutes ?? holdWpForSegment.displayStyle?.segmentTargetMinutes); + const hasFixedTime = holdWpForSegment && holdWpForSegment.segmentMode === 'fixed_time' && (segTarget != null && segTarget !== ''); + let arrivalEntry; + let speedKmhForLeg = points[i].speed || 800; + if (hasFixedTime) { + const targetMin = Number(segTarget); + const deltaMin = targetMin - effectiveTime[i]; + if (deltaMin > 0.001 && distToEntry > 0) { + arrivalEntry = targetMin; + speedKmhForLeg = (distToEntry / 1000) / (deltaMin / 60); + speedKmhForLeg = Math.round(speedKmhForLeg * 10) / 10; + } else { + arrivalEntry = effectiveTime[i] + (distToEntry / 1000) * (60 / speedKmhForLeg); + } + } else { + const travelToEntryMin = (distToEntry / 1000) * (60 / speedKmhForLeg); + arrivalEntry = effectiveTime[i] + travelToEntryMin; + } 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++) { + for (let k = 1; k < Math.min(holdPathSlice.length, 200); 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 exitIdxOnLoop = holdPathSlice.length - 1; let minD = 1e9; - for (let k = 0; k <= loopEndIdx; k++) { + for (let k = 0; k < holdPathSlice.length; 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 holdSpeedKmh = points[i + 1].speed || 800; + const HOLD_SPEED_KMH = 800; + const speedMpMin = (HOLD_SPEED_KMH * 1000) / 60; const requiredDistAtK10 = (holdEndTime - arrivalEntry) * speedMpMin; let n = Math.ceil((requiredDistAtK10 - holdExitDistanceOnLoop) / holdLoopLength); if (n < 0 || !Number.isFinite(n)) n = 0; @@ -3838,7 +4074,7 @@ export default { }); } const distExitToNext = this.pathSliceDistance(toNextSlice); - const travelExitMin = (distExitToNext / 1000) * (60 / speedKmh); + const travelExitMin = (distExitToNext / 1000) * (60 / holdSpeedKmh); const arrivalNext = segmentEndTime + travelExitMin; effectiveTime[i + 1] = holdEndTime; if (i + 2 < points.length) effectiveTime[i + 2] = arrivalNext; @@ -3854,7 +4090,8 @@ export default { const holdEntryAngle = holdCenter && entryPos && holdRadius != null ? this.angleFromCenterToPoint(holdCenter.lng, holdCenter.lat, entryPos.lng, entryPos.lat) : null; - segments.push({ startTime: effectiveTime[i], endTime: arrivalEntry, startPos: posCur, endPos: entryPos, type: 'fly', legIndex: i, pathSlice: toEntrySlice }); + segments.push({ startTime: effectiveTime[i], endTime: arrivalEntry, startPos: posCur, endPos: entryPos, type: 'fly', legIndex: i, pathSlice: toEntrySlice, speedKmh: speedKmhForLeg }); + const holdEntryToExitPath = holdClosedLoopPath.slice(0, exitIdxOnLoop + 1); segments.push({ startTime: arrivalEntry, endTime: segmentEndTime, @@ -3866,16 +4103,18 @@ export default { holdClosedLoopPath, holdLoopLength, holdExitDistanceOnLoop, - speedKmh: points[i].speed || 800, + holdEntryToExitPath, + holdN: n, + speedKmh: HOLD_SPEED_KMH, + holdEndTime, holdCenter, holdRadius, holdCircumference, holdClockwise, holdEntryAngle }); - segments.push({ startTime: segmentEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice }); - i++; - continue; + segments.push({ startTime: segmentEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice, speedKmh: holdSpeedKmh }); + continue; // 不 i++,让下次迭代处理下一航段(含连续盘旋点如 WP2→WP3 均为盘旋) } const dist = this.segmentDistance(points[i], points[i + 1]); const speedKmh = points[i].speed || 800; @@ -3902,7 +4141,7 @@ export default { effectiveTime[i + 1] = Math.max(actualArrival, scheduled); const posCur = { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt }; const posNext = { lng: points[i + 1].lng, lat: points[i + 1].lat, alt: points[i + 1].alt }; - segments.push({ startTime: effectiveTime[i], endTime: actualArrival, startPos: posCur, endPos: posNext, type: 'fly', legIndex: i }); + segments.push({ startTime: effectiveTime[i], endTime: actualArrival, startPos: posCur, endPos: posNext, type: 'fly', legIndex: i, speedKmh: speedKmh }); if (actualArrival < effectiveTime[i + 1]) { segments.push({ startTime: actualArrival, endTime: effectiveTime[i + 1], startPos: posNext, endPos: posNext, type: 'wait', legIndex: i }); } @@ -3971,12 +4210,24 @@ export default { return s.startPos; } if (s.type === 'hold' && s.holdPath && s.holdPath.length) { - // 飞机一直绕闭合环盘旋,不静止;到设定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 n = Math.max(0, s.holdN != null ? s.holdN : 0); + const totalFly = (s.holdExitDistanceOnLoop || 0) + n * s.holdLoopLength; + if (totalFly <= 0) return s.startPos; + if (distM >= totalFly) return s.endPos; + if (distM < n * s.holdLoopLength) { + const distOnLoop = ((distM % s.holdLoopLength) + s.holdLoopLength) % s.holdLoopLength; + const tPath = distOnLoop / s.holdLoopLength; + return this.getPositionAlongPathSlice(s.holdClosedLoopPath, tPath); + } + const distToExit = distM - n * s.holdLoopLength; + const exitDist = s.holdExitDistanceOnLoop || 1; + const tPath = Math.min(1, distToExit / exitDist); + if (s.holdEntryToExitPath && s.holdEntryToExitPath.length > 0) { + return this.getPositionAlongPathSlice(s.holdEntryToExitPath, tPath); + } + return this.getPositionAlongPathSlice(s.holdClosedLoopPath, distToExit / s.holdLoopLength); } return this.getPositionAlongPathSlice(s.holdPath, t); } @@ -4005,7 +4256,10 @@ export default { const cesiumMap = this.$refs.cesiumMap; let pathData = null; if (cesiumMap && cesiumMap.getRoutePathWithSegmentIndices) { - const ret = cesiumMap.getRoutePathWithSegmentIndices(waypoints); + const cachedRadii = (routeId != null && cesiumMap._routeHoldRadiiByRoute && cesiumMap._routeHoldRadiiByRoute[routeId]) ? cesiumMap._routeHoldRadiiByRoute[routeId] : {}; + const cachedEllipse = (routeId != null && cesiumMap._routeHoldEllipseParamsByRoute && cesiumMap._routeHoldEllipseParamsByRoute[routeId]) ? cesiumMap._routeHoldEllipseParamsByRoute[routeId] : {}; + const opts = (Object.keys(cachedRadii).length > 0 || Object.keys(cachedEllipse).length > 0) ? { holdRadiusByLegIndex: cachedRadii, holdEllipseParamsByLegIndex: cachedEllipse } : {}; + const ret = cesiumMap.getRoutePathWithSegmentIndices(waypoints, opts); if (ret.path && ret.path.length > 0 && ret.segmentEndIndices && ret.segmentEndIndices.length > 0) { pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} }; } @@ -4018,28 +4272,44 @@ export default { 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 holdEndTime = s.holdEndTime != null ? s.holdEndTime : this.waypointStartTimeToMinutesDecimal(waypoints[i + 1]?.startTime); + const holdWp = waypoints[i + 1]; + const segTarget = holdWp && (holdWp.segmentTargetMinutes ?? holdWp.displayStyle?.segmentTargetMinutes); + const arrivalAtHold = (holdWp && holdWp.segmentMode === 'fixed_time' && segTarget != null && segTarget !== '') + ? Number(segTarget) : s.startTime; + const holdDurationMin = Math.max(0, holdEndTime - arrivalAtHold); + const speedKmh = s.speedKmh != null ? s.speedKmh : (Number(holdWp?.speed) || 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 isHoldEllipse = waypoints[i + 1] && (waypoints[i + 1].pointType || waypoints[i + 1].point_type) === 'hold_ellipse'; + const isEllipse = isHoldEllipse || s.holdRadius == null; + if (isEllipse && !isHoldEllipse && 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 }; + if (out && out.semiMajor != null && out.semiMinor != null) { + holdEllipseParamsByLegIndex[i] = { + semiMajor: out.semiMajor, + semiMinor: out.semiMinor, + headingDeg + }; + } } else if (!isEllipse && cesiumMap.computeHoldRadiusForDuration) { - const R = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, clockwise, totalHoldDistM); - if (R != null && Number.isFinite(R)) holdRadiusByLegIndex[i] = R; + let R = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, clockwise, totalHoldDistM); + if (R == null || !Number.isFinite(R)) { + R = totalHoldDistM / (2 * Math.PI); + } + if (R != null && Number.isFinite(R) && R > 0) { + holdRadiusByLegIndex[i] = R; + } } } const hasCircle = Object.keys(holdRadiusByLegIndex).length > 0; @@ -4047,7 +4317,7 @@ export default { if (hasCircle || hasEllipse) { let pathData2 = null; let segments2 = null; - for (let iter = 0; iter < 2; iter++) { + for (let iter = 0; iter < 4; 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 || {} }; @@ -4060,20 +4330,26 @@ export default { 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 holdWpCircle = waypoints[i + 1]; + const holdEndTimeCircle = s.holdEndTime != null ? s.holdEndTime : this.waypointStartTimeToMinutesDecimal(holdWpCircle?.startTime); + const segTargetCircle = holdWpCircle && (holdWpCircle.segmentTargetMinutes ?? holdWpCircle.displayStyle?.segmentTargetMinutes); + const arrivalAtHoldCircle = (holdWpCircle && holdWpCircle.segmentMode === 'fixed_time' && segTargetCircle != null && segTargetCircle !== '') + ? Number(segTargetCircle) : s.startTime; + const holdDurationMin = Math.max(0, holdEndTimeCircle - arrivalAtHoldCircle); + const speedKmh = s.speedKmh != null ? s.speedKmh : (Number(holdWpCircle?.speed) || 800); const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000; const prevWp = waypoints[i]; - const holdWp = waypoints[i + 1]; + const holdWp = holdWpCircle; 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; + let Rnew = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, s.holdClockwise !== false, totalHoldDistM); + if (Rnew == null || !Number.isFinite(Rnew)) Rnew = totalHoldDistM / (2 * Math.PI); + if (Rnew != null && Number.isFinite(Rnew) && Rnew > 0) { nextRadii[i] = Rnew; + if (holdRadiusByLegIndex[i] == null || Math.abs(nextRadii[i] - holdRadiusByLegIndex[i]) > 1) changed = true; } } Object.assign(holdRadiusByLegIndex, nextRadii); @@ -4084,9 +4360,14 @@ export default { if (s.type !== 'hold' || s.holdRadius != null || s.holdCenter == null) continue; const i = s.legIndex; const holdWp = waypoints[i + 1]; + if ((holdWp && (holdWp.pointType || holdWp.point_type) === 'hold_ellipse')) continue; 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 holdEndTime = s.holdEndTime != null ? s.holdEndTime : this.waypointStartTimeToMinutesDecimal(holdWp?.startTime); + const segTargetEllipse = holdWp && (holdWp.segmentTargetMinutes ?? holdWp.displayStyle?.segmentTargetMinutes); + const arrivalAtHold = (holdWp && holdWp.segmentMode === 'fixed_time' && segTargetEllipse != null && segTargetEllipse !== '') + ? Number(segTargetEllipse) : s.startTime; + const holdDurationMin = Math.max(0, holdEndTime - arrivalAtHold); + const speedKmh = s.speedKmh != null ? s.speedKmh : (Number(holdWp?.speed) || 800); const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000; const prevWp = waypoints[i]; const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp; @@ -4099,13 +4380,15 @@ export default { 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 smj = out.semiMajor; + const smn = out.semiMinor; 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 (!old || Math.abs(smj - old.semiMajor) > 1) changed = true; + holdEllipseParamsByLegIndex[i] = { semiMajor: smj, semiMinor: smn, headingDeg }; } } } - if (!changed || iter === 1) break; + if (!changed || iter === 3) break; } if (pathData2) pathData = pathData2; if (segments2) segments = segments2; @@ -4211,7 +4494,7 @@ export default { if (this.addHoldContext.mode === 'drawing') { const holdParams = this.addHoldForm.holdType === 'hold_circle' ? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise } - : { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise }; + : { edgeLength: (this.addHoldForm.edgeLengthKm != null ? this.addHoldForm.edgeLengthKm : 20) * 1000, clockwise: this.addHoldForm.clockwise }; if (this.$refs.cesiumMap && this.$refs.cesiumMap.insertHoldBetweenLastTwo) { this.$refs.cesiumMap.insertHoldBetweenLastTwo(holdParams); } @@ -4233,7 +4516,7 @@ export default { const baseSeq = prevWp.seq != null ? Number(prevWp.seq) : legIndex + 1; const holdParams = this.addHoldForm.holdType === 'hold_circle' ? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise } - : { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise }; + : { edgeLength: (this.addHoldForm.edgeLengthKm != null ? this.addHoldForm.edgeLengthKm : 20) * 1000, clockwise: this.addHoldForm.clockwise }; const startTime = this.addHoldForm.startTimeMinutes !== '' && this.addHoldForm.startTimeMinutes != null && !Number.isNaN(Number(this.addHoldForm.startTimeMinutes)) ? this.minutesToStartTime(Number(this.addHoldForm.startTimeMinutes)) : (nextWp.startTime || 'K+01:00'); @@ -4304,10 +4587,24 @@ export default { const span = Math.max(0, maxMinutes - minMinutes) || 120; const minutesFromK = minMinutes + (val / 100) * span; const sign = minutesFromK >= 0 ? '+' : '-'; - const absMin = Math.abs(Math.floor(minutesFromK)); - const hours = Math.floor(absMin / 60); - const minutes = absMin % 60; - return `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`; + const totalSeconds = Math.floor(Math.abs(minutesFromK) * 60); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + }, + + onTimelineHover(e) { + const wrap = this.$refs.timelineSliderWrap; + if (!wrap) return; + const rect = wrap.getBoundingClientRect(); + const percent = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)); + this.timelineHoverPercent = percent; + this.timelineHoverTime = this.formatTimeTooltip(percent); + this.timelineHoverVisible = true; + }, + onTimelineLeave() { + this.timelineHoverVisible = false; }, async selectPlan(plan) { if (plan && plan.id) { @@ -4457,6 +4754,10 @@ export default { if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(route.id, styleRes.data); } catch (_) {} } + if (waypoints.some(wp => this.isHoldWaypoint(wp))) { + const { minMinutes, maxMinutes } = this.getDeductionTimeRange(); + this.getPositionAtMinutesFromK(waypoints, minMinutes, minMinutes, maxMinutes, route.id); + } this.$refs.cesiumMap.renderRouteWaypoints(waypoints, route.id, route.platformId, route.platform, this.parseRouteStyle(route.attributes)); this.$nextTick(() => this.updateDeductionPositions()); } @@ -5002,6 +5303,33 @@ export default { .timeline-slider { flex: 1; margin: 0 20px; + position: relative; +} + +.timeline-hover-time { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + transform: translateX(-50%); + padding: 4px 10px; + background: rgba(0, 0, 0, 0.85); + color: #fff; + font-size: 12px; + font-weight: 500; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + z-index: 10; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} +.timeline-hover-time::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: rgba(0, 0, 0, 0.85); } .compact-slider { @@ -5068,6 +5396,16 @@ export default { min-width: 24px; text-align: center; } +.speed-text.clickable { + cursor: pointer; + padding: 2px 4px; + display: inline-flex; + align-items: center; +} +.speed-text .el-icon--right { + margin-left: 4px; + font-size: 10px; +} .system-status { display: flex; diff --git a/ruoyi-ui/src/views/dialogs/RouteEditDialog.vue b/ruoyi-ui/src/views/dialogs/RouteEditDialog.vue index 8140dd2..a68cfe6 100644 --- a/ruoyi-ui/src/views/dialogs/RouteEditDialog.vue +++ b/ruoyi-ui/src/views/dialogs/RouteEditDialog.vue @@ -231,10 +231,20 @@ - + + + + @@ -252,8 +262,8 @@ @@ -601,44 +611,79 @@ export default { attrs.lineStyle = { ...this.styleForm.line } return JSON.stringify(attrs) }, - /** 将 startTime 字符串(如 K+00:40:00)转为相对 K 的分钟数 */ + /** 将 startTime 字符串(如 K+00:40:00 或 K+00:19:30)转为相对 K 的分钟数(支持秒,保留一位小数精度) */ startTimeToMinutes(s) { if (!s || typeof s !== 'string') return 0 - const m = s.match(/K([+-])(\d{2}):(\d{2})/) + const m = s.match(/K([+-])(\d{2}):(\d{2})(?::(\d{2}))?/) if (!m) return 0 const sign = m[1] === '+' ? 1 : -1 const h = parseInt(m[2], 10) const min = parseInt(m[3], 10) - return sign * (h * 60 + min) + const sec = m[4] != null ? parseInt(m[4], 10) : 0 + return sign * (h * 60 + min + sec / 60) }, - /** 将相对 K 的分钟数转为 startTime 字符串 */ + /** 将相对 K 的分钟数(可含小数)转为 startTime 字符串,最小单位秒 */ minutesToStartTime(m) { const num = Number(m) if (isNaN(num)) return 'K+00:00:00' if (num >= 0) { - const h = Math.floor(num / 60) - const min = num % 60 - return `K+${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:00` + const totalSec = Math.round(num * 60) + const h = Math.floor(totalSec / 3600) + const min = Math.floor((totalSec % 3600) / 60) + const sec = totalSec % 60 + return `K+${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}` } const abs = Math.abs(num) - const h = Math.floor(abs / 60) - const min = abs % 60 - return `K-${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:00` + const totalSec = Math.round(abs * 60) + const h = Math.floor(totalSec / 3600) + const min = Math.floor((totalSec % 3600) / 60) + const sec = totalSec % 60 + return `K-${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}` + }, + segmentModeLabel(mode) { + if (mode === 'fixed_speed') return '定速' + if (mode === 'fixed_time') return '定时' + return '无' + }, + /** 数值保留一位小数显示,空/非数字显示 — */ + formatNumOneDecimal(val) { + if (val === undefined || val === null || val === '') return '—' + const n = Number(val) + return isNaN(n) ? String(val) : Number(n.toFixed(1)) + }, + /** 两航点间近似距离(米),含高度差,用于定时点反算上一航点速度 */ + segmentDistance(wp1, wp2) { + const R = 6371000 + const lat1 = (wp1.lat * Math.PI) / 180 + const lat2 = (wp2.lat * Math.PI) / 180 + const dlat = ((wp2.lat - wp1.lat) * Math.PI) / 180 + const dlng = ((wp2.lng - wp1.lng) * Math.PI) / 180 + const a = Math.sin(dlat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dlng / 2) ** 2 + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + const horizontal = R * c + const dalt = (wp2.alt || 0) - (wp1.alt || 0) + return Math.sqrt(horizontal * horizontal + dalt * dalt) }, isHoldRow(row) { const t = (row && (row.pointType || row.point_type)) || 'normal' return t === 'hold_circle' || t === 'hold_ellipse' }, syncWaypointsTableData(waypoints) { - this.waypointsTableData = (waypoints || []).map(wp => ({ - ...wp, - minutesFromK: this.startTimeToMinutes(wp.startTime), + this.waypointsTableData = (waypoints || []).map(wp => { + const disp = wp.displayStyle; + return { + ...wp, + minutesFromK: this.startTimeToMinutes(wp.startTime), + segmentMode: wp.segmentMode || (disp && disp.segmentMode) || null, + segmentTargetMinutes: (disp && disp.segmentTargetMinutes != null) ? Number(disp.segmentTargetMinutes) : (wp.segmentTargetMinutes != null ? Number(wp.segmentTargetMinutes) : null), + segmentTargetSpeed: (disp && disp.segmentTargetSpeed != null) ? Number(disp.segmentTargetSpeed) : (wp.segmentTargetSpeed != null ? Number(wp.segmentTargetSpeed) : null), labelFontSize: wp.labelFontSize != null ? Number(wp.labelFontSize) : 14, labelColor: wp.labelColor || '#333333', pixelSize: wp.pixelSize != null ? Number(wp.pixelSize) : 10, color: wp.color || '#f1f5f9', outlineColor: wp.outlineColor || '#64748b' - })) + }; + }); this.waypointsEditMode = false }, formatNum(val) { @@ -656,7 +701,23 @@ export default { }, getWaypointsPayloadForSave() { const routeId = this.form.id - return this.waypointsTableData.map(row => ({ + const rows = this.waypointsTableData + // 定时点:根据“本航点定时到达(或相对K时)”反算上一航点速度,可与相对K时分离支持“定时到达+盘旋至相对K时出发” + const prevSpeedByIndex = {} + for (let i = 1; i < rows.length; i++) { + if (rows[i].segmentMode === 'fixed_time') { + const prev = rows[i - 1] + const curr = rows[i] + const currArrivalMin = (curr.segmentTargetMinutes != null && curr.segmentTargetMinutes !== '') ? Number(curr.segmentTargetMinutes) : Number(curr.minutesFromK) + const deltaMin = currArrivalMin - Number(prev.minutesFromK) + if (deltaMin > 0.001) { + const distM = this.segmentDistance(prev, curr) + const speedKmh = (distM / 1000) / (deltaMin / 60) + prevSpeedByIndex[i - 1] = Math.round(speedKmh * 10) / 10 + } + } + } + return rows.map((row, idx) => ({ id: row.id, routeId, seq: row.seq, @@ -664,11 +725,14 @@ export default { lng: row.lng, lat: row.lat, alt: row.alt, - speed: row.speed, + speed: prevSpeedByIndex[idx] != null ? prevSpeedByIndex[idx] : (row.speed != null ? Math.round(Number(row.speed) * 10) / 10 : null), startTime: this.minutesToStartTime(row.minutesFromK), turnAngle: row.turnAngle != null ? row.turnAngle : 0, pointType: row.pointType || null, holdParams: row.holdParams || null, + segmentMode: row.segmentMode || null, + segmentTargetMinutes: row.segmentTargetMinutes != null ? row.segmentTargetMinutes : null, + segmentTargetSpeed: row.segmentTargetSpeed != null ? row.segmentTargetSpeed : null, labelFontSize: row.labelFontSize != null ? row.labelFontSize : 14, labelColor: row.labelColor || '#333333', color: row.color != null && row.color !== '' ? row.color : '#f1f5f9', diff --git a/ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue b/ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue index f33545b..594c4dd 100644 --- a/ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue +++ b/ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue @@ -66,16 +66,52 @@ > - + + + + + 定速 + 定时 + +
定速:移动下一航点会按距离÷速度重算下一航点的相对K时;定时:移动本航点会按距离÷时间重算上一航点的速度。
+ + +
+ 椭圆 - - - - 顺时针 逆时针 + +
盘旋点需设置定时且定时 < 相对K时(到达时间早于切出时间才有盘旋时长)。半径/长短轴由相对K时、定时与速度自动计算,并与最小值比较;若计算值小于最小值将提示冲突,请调整相对K时或速度。
@@ -122,6 +159,7 @@ v-model="formData.minutesFromK" :min="-9999" :max="9999" + :precision="1" controls-position="right" placeholder="正数 K 后,负数 K 前" class="full-width-input" @@ -170,15 +208,15 @@ export default { speed: 800, turnAngle: 0, minutesFromK: 0, + segmentMode: null, + segmentTargetMinutes: null, + segmentTargetSpeed: null, currentIndex: -1, totalPoints: 0, isBankDisabled: false, pointType: 'normal', - holdRadius: 500, - holdSemiMajor: 500, - holdSemiMinor: 300, - holdHeadingDeg: 0, holdClockwise: true, + holdEdgeLengthKm: 20, labelFontSize: 14, labelColor: '#334155', pixelSize: 10, @@ -229,16 +267,14 @@ export default { const locked = (index === 0) || (total > 0 && index === total - 1); const pt = (this.waypoint.pointType || this.waypoint.point_type) || 'normal'; - let holdRadius = 500, holdSemiMajor = 500, holdSemiMinor = 300, holdHeadingDeg = 0, holdClockwise = true; + let holdClockwise = true; + let holdEdgeLengthKm = 20; try { const raw = this.waypoint.holdParams || this.waypoint.hold_params; if (raw) { const p = typeof raw === 'string' ? JSON.parse(raw) : raw; - holdRadius = p.radius != null ? p.radius : 500; - holdSemiMajor = p.semiMajor ?? p.semiMajorAxis ?? 500; - holdSemiMinor = p.semiMinor ?? p.semiMinorAxis ?? 300; - holdHeadingDeg = p.headingDeg ?? 0; holdClockwise = p.clockwise !== false; + if (p.edgeLength != null) holdEdgeLengthKm = Math.max(1, Math.min(200, Number(p.edgeLength) / 1000)); } } catch (e) {} const labelFontSize = this.waypoint.labelFontSize != null ? Number(this.waypoint.labelFontSize) : 14; @@ -246,21 +282,23 @@ export default { const pixelSize = this.waypoint.pixelSize != null ? Number(this.waypoint.pixelSize) : 10; const color = this.waypoint.color || '#f1f5f9'; const outlineColor = this.waypoint.outlineColor != null ? this.waypoint.outlineColor : '#64748b'; + const segmentMode = this.waypoint.segmentMode || (this.waypoint.displayStyle && this.waypoint.displayStyle.segmentMode) || null; + const disp = this.waypoint.displayStyle; this.formData = { name: this.waypoint.name || '', alt: this.waypoint.alt !== undefined && this.waypoint.alt !== null ? Number(this.waypoint.alt) : 0, speed: this.waypoint.speed !== undefined && this.waypoint.speed !== null ? Number(this.waypoint.speed) : 0, minutesFromK: this.startTimeToMinutes(this.waypoint.startTime), + segmentMode: segmentMode === 'fixed_speed' || segmentMode === 'fixed_time' ? segmentMode : null, + segmentTargetMinutes: (disp && disp.segmentTargetMinutes != null) ? Number(disp.segmentTargetMinutes) : (this.waypoint.segmentTargetMinutes != null ? Number(this.waypoint.segmentTargetMinutes) : null), + segmentTargetSpeed: (disp && disp.segmentTargetSpeed != null) ? Number(disp.segmentTargetSpeed) : (this.waypoint.segmentTargetSpeed != null ? Number(this.waypoint.segmentTargetSpeed) : (this.waypoint.speed != null ? Number(this.waypoint.speed) : null)), currentIndex: index, totalPoints: total, isBankDisabled: locked, - turnAngle: locked ? 0 : (Number(this.waypoint.turnAngle) || 0), + turnAngle: locked ? 0 : (this.waypoint.turnAngle != null ? Number(this.waypoint.turnAngle) : 45), pointType: pt, - holdRadius, - holdSemiMajor, - holdSemiMinor, - holdHeadingDeg, holdClockwise, + holdEdgeLengthKm, labelFontSize: Math.min(28, Math.max(10, labelFontSize)), labelColor, pixelSize: Math.min(24, Math.max(4, pixelSize)), @@ -279,33 +317,46 @@ export default { }, saveWaypoint() { this.$refs.formRef.validate((valid) => { - if (valid) { - const { minutesFromK, ...rest } = this.formData; - const startTimeStr = this.minutesToStartTime(minutesFromK); - const payload = { - ...this.waypoint, - ...rest, - startTime: startTimeStr, - labelFontSize: this.formData.labelFontSize, - labelColor: this.formData.labelColor, - pixelSize: this.formData.pixelSize, - color: this.formData.color, - outlineColor: this.formData.outlineColor - }; - if (this.formData.pointType && this.formData.pointType !== 'normal') { - payload.pointType = this.formData.pointType; - payload.holdParams = this.formData.pointType === 'hold_circle' - ? JSON.stringify({ radius: this.formData.holdRadius, clockwise: this.formData.holdClockwise }) - : JSON.stringify({ - semiMajor: this.formData.holdSemiMajor, - semiMinor: this.formData.holdSemiMinor, - headingDeg: this.formData.holdHeadingDeg, - clockwise: this.formData.holdClockwise - }); + if (!valid) return; + const { minutesFromK, ...rest } = this.formData; + if (this.isHoldWaypoint && this.formData.segmentMode === 'fixed_time') { + const target = this.formData.segmentTargetMinutes; + if (target == null || target === '') { + this.$message.warning('盘旋点需设置定时到达时间,以便计算盘旋半径/长短轴'); + return; + } + if (Number(target) >= Number(minutesFromK)) { + this.$message.warning('盘旋点要求定时 < 相对K时(到达时间须早于切出时间),请调整定时或相对K时'); + return; + } + } + const startTimeStr = this.minutesToStartTime(minutesFromK); + const segmentTargetMinutes = this.formData.segmentMode === 'fixed_time' && this.formData.segmentTargetMinutes != null ? this.formData.segmentTargetMinutes : null; + const segmentTargetSpeed = this.formData.segmentMode === 'fixed_speed' && this.formData.segmentTargetSpeed != null ? this.formData.segmentTargetSpeed : null; + const payload = { + ...this.waypoint, + ...rest, + startTime: startTimeStr, + segmentMode: this.formData.segmentMode || null, + segmentTargetMinutes, + segmentTargetSpeed, + displayStyle: { ...(this.waypoint.displayStyle || {}), segmentMode: this.formData.segmentMode, segmentTargetMinutes, segmentTargetSpeed }, + labelFontSize: this.formData.labelFontSize, + labelColor: this.formData.labelColor, + pixelSize: this.formData.pixelSize, + color: this.formData.color, + outlineColor: this.formData.outlineColor + }; + if (this.formData.pointType && this.formData.pointType !== 'normal') { + payload.pointType = this.formData.pointType; + const holdParams = { clockwise: this.formData.holdClockwise }; + if (this.formData.pointType === 'hold_ellipse') { + holdParams.edgeLength = (this.formData.holdEdgeLengthKm != null ? this.formData.holdEdgeLengthKm : 20) * 1000; } - this.$emit('save', payload); - this.closeDialog(); + payload.holdParams = JSON.stringify(holdParams); } + this.$emit('save', payload); + this.closeDialog(); }); }, /** 将 startTime 字符串(如 K+00:40:00)转为相对 K 的分钟数 */ @@ -448,6 +499,21 @@ export default { color: #e6a23c; } +.form-hold-tip { + margin-bottom: 12px; + padding: 6px 0; +} + +.segment-mode-group { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.segment-target-item { + margin-top: 8px; +} + .color-value { margin-left: 8px; font-size: 12px;