Browse Source

盘旋的一种方式

master
ctw 2 months ago
parent
commit
9114c295ba
  1. 2
      ruoyi-admin/src/main/resources/application-druid.yml
  2. 26
      ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java
  3. 12
      ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml
  4. 3
      ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue
  5. 1012
      ruoyi-ui/src/views/cesiumMap/index.vue
  6. 5
      ruoyi-ui/src/views/childRoom/RightPanel.vue
  7. 429
      ruoyi-ui/src/views/childRoom/index.vue
  8. 81
      ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue

2
ruoyi-admin/src/main/resources/application-druid.yml

@ -8,7 +8,7 @@ spring:
master:
url: jdbc:mysql://localhost:3306/ry?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: 123456
password: A20040303ctw!
# 从库数据源
slave:
# 从数据源开关/默认关闭

26
ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java

@ -55,6 +55,14 @@ public class RouteWaypoints extends BaseEntity
@Excel(name = "转弯角度 (用于计算转弯半径)")
private Double turnAngle;
/** 航点类型: normal-普通, hold_circle-圆形盘旋, hold_ellipse-椭圆盘旋 */
@Excel(name = "航点类型")
private String pointType;
/** 盘旋参数JSON: 圆(radius,clockwise) 椭圆(semiMajor,semiMinor,headingDeg,clockwise) */
@Excel(name = "盘旋参数")
private String holdParams;
public void setId(Long id)
{
this.id = id;
@ -155,6 +163,22 @@ public class RouteWaypoints extends BaseEntity
return turnAngle;
}
public void setPointType(String pointType) {
this.pointType = pointType;
}
public String getPointType() {
return pointType;
}
public void setHoldParams(String holdParams) {
this.holdParams = holdParams;
}
public String getHoldParams() {
return holdParams;
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
@ -168,6 +192,8 @@ public class RouteWaypoints extends BaseEntity
.append("speed", getSpeed())
.append("startTime", getStartTime())
.append("turnAngle", getTurnAngle())
.append("pointType", getPointType())
.append("holdParams", getHoldParams())
.toString();
}

12
ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml

@ -15,10 +15,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="speed" column="speed" />
<result property="startTime" column="start_time" />
<result property="turnAngle" column="turn_angle" />
<result property="pointType" column="point_type" />
<result property="holdParams" column="hold_params" />
</resultMap>
<sql id="selectRouteWaypointsVo">
select id, route_id, name, seq, lat, lng, alt, speed, start_time, turn_angle from route_waypoints
select id, route_id, name, seq, lat, lng, alt, speed, start_time, turn_angle, point_type, hold_params from route_waypoints
</sql>
<select id="selectRouteWaypointsList" parameterType="RouteWaypoints" resultMap="RouteWaypointsResult">
@ -33,6 +35,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="speed != null "> and speed = #{speed}</if>
<if test="startTime != null and startTime != ''"> and start_time = #{startTime}</if>
<if test="turnAngle != null "> and turn_angle = #{turnAngle}</if>
<if test="pointType != null and pointType != ''"> and point_type = #{pointType}</if>
<if test="holdParams != null and holdParams != ''"> and hold_params = #{holdParams}</if>
</where>
</select>
@ -57,6 +61,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="speed != null">speed,</if>
<if test="startTime != null and startTime != ''">start_time,</if>
<if test="turnAngle != null">turn_angle,</if>
<if test="pointType != null and pointType != ''">point_type,</if>
<if test="holdParams != null">hold_params,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="routeId != null">#{routeId},</if>
@ -68,6 +74,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="speed != null">#{speed},</if>
<if test="startTime != null and startTime != ''">#{startTime},</if>
<if test="turnAngle != null">#{turnAngle},</if>
<if test="pointType != null and pointType != ''">#{pointType},</if>
<if test="holdParams != null">#{holdParams},</if>
</trim>
</insert>
@ -83,6 +91,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="speed != null">speed = #{speed},</if>
<if test="startTime != null and startTime != ''">start_time = #{startTime},</if>
<if test="turnAngle != null">turn_angle = #{turnAngle},</if>
<if test="pointType != null">point_type = #{pointType},</if>
<if test="holdParams != null">hold_params = #{holdParams},</if>
</trim>
where id = #{id}
</update>

3
ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue

@ -45,6 +45,9 @@ export default {
{ id: 'polygon', name: '面', icon: 'el-icon-house' },
{ id: 'rectangle', name: '矩形', icon: 'jx' },
{ id: 'circle', name: '圆形', icon: 'circle' },
{ id: 'ellipse', name: '椭圆', icon: 'el-icon-oval' },
{ id: 'hold_circle', name: '圆形盘旋', icon: 'circle' },
{ id: 'hold_ellipse', name: '椭圆盘旋', icon: 'el-icon-oval' },
{ id: 'sector', name: '扇形', icon: 'sx' },
{ id: 'arrow', name: '箭头', icon: 'el-icon-right' },
{ id: 'text', name: '文本', icon: 'el-icon-document' },

1012
ruoyi-ui/src/views/cesiumMap/index.vue

File diff suppressed because it is too large

5
ruoyi-ui/src/views/childRoom/RightPanel.vue

@ -42,6 +42,7 @@
</div>
<div class="tree-item-actions">
<i class="el-icon-plus" title="新建航线" @click.stop="handleCreateRouteForPlan(plan)"></i>
<i class="el-icon-aim" title="先画盘旋再画航线" @click.stop="handleCreateRouteWithHold(plan)"></i>
<i class="el-icon-edit" title="编辑" @click.stop="handleOpenPlanDialog(plan)"></i>
<i class="el-icon-delete" title="删除" @click.stop="handleDeletePlan(plan)"></i>
</div>
@ -452,6 +453,10 @@ export default {
this.$emit('create-route', plan)
},
handleCreateRouteWithHold(plan) {
this.$emit('create-route-with-hold', plan)
},
handleDeletePlan(plan) {
this.$confirm(`是否确认删除方案 "${plan.name}"?这将同时删除该方案下的所有航线。`, "警告", {
confirmButtonText: "确定",

429
ruoyi-ui/src/views/childRoom/index.vue

@ -8,14 +8,19 @@
:tool-mode="drawDom ? 'ranging' : (airspaceDrawDom ? 'airspace' : 'airspace')"
:scaleConfig="scaleConfig"
@draw-complete="handleMapDrawComplete"
@drawing-points-update="missionDrawingPointsCount = $event"
@open-waypoint-dialog="handleOpenWaypointEdit"
@open-route-dialog="handleOpenRouteEdit"
@scale-click="handleScaleClick" />
@open-route-dialog="handleOpenRouteEdit" />
<div class="map-overlay-text">
<i class="el-icon-location-outline text-3xl mb-2 block"></i>
<p>二维GIS地图区域</p>
<p class="text-sm mt-1">支持标绘/航线/空域/实时态势</p>
</div>
<div v-if="missionDrawingActive && missionDrawingPointsCount >= 2" class="mission-drawing-actions" style="position:absolute; bottom:16px; left:50%; transform:translateX(-50%); z-index:10; display:flex; gap:8px; align-items:center;">
<span class="text-white text-sm"> {{ missionDrawingPointsCount }} 个航点右键结束</span>
<el-button type="primary" size="small" @click="openAddHoldDuringDrawing">插入盘旋</el-button>
</div>
<!-- 地图中间的浮动红点触发左侧菜单 -->
<div
@ -155,6 +160,7 @@
@hide="hideRightPanel"
@select-route="selectRoute"
@create-route="createRoute"
@create-route-with-hold="openAddHoldThenCreateRoute"
@delete-route="handleDeleteRoute"
@select-plan="selectPlan"
@create-plan="createPlan"
@ -226,13 +232,56 @@
</div>
</div>
</div>
<div v-if="deductionWarnings.length > 0" class="deduction-warnings">
<div v-if="deductionWarnings.length > 0 || hasEarlyArrivalLegs" class="deduction-warnings">
<i class="el-icon-warning-outline"></i>
<span>{{ deductionWarnings[0] }}</span>
<span>{{ deductionWarnings[0] || '存在航段将提前到达下一航点。' }}</span>
<el-tooltip v-if="deductionWarnings.length > 1" :content="deductionWarnings.join(';')" placement="top">
<span class="warnings-more"> {{ deductionWarnings.length }} </span>
</el-tooltip>
<el-button v-if="hasEarlyArrivalLegs" type="text" size="mini" @click="openAddHoldFromFirstEarly">在此添加盘旋</el-button>
</div>
<el-dialog :title="addHoldDialogTitle" :visible.sync="showAddHoldDialog" width="420px" append-to-body>
<div v-if="addHoldContext" class="add-hold-tip">{{ addHoldDialogTip }}</div>
<el-form :model="addHoldForm" label-width="100px" size="small">
<el-form-item label="盘旋类型">
<el-radio-group v-model="addHoldForm.holdType">
<el-radio label="hold_circle">圆形</el-radio>
<el-radio label="hold_ellipse">椭圆</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="addHoldForm.holdType === 'hold_circle'" label="半径(米)">
<el-input-number v-model="addHoldForm.radius" :min="100" :max="50000" style="width:100%" />
</el-form-item>
<template v-if="addHoldForm.holdType === 'hold_ellipse'">
<el-form-item label="长半轴(米)">
<el-input-number v-model="addHoldForm.semiMajor" :min="100" :max="50000" style="width:100%" />
</el-form-item>
<el-form-item label="短半轴(米)">
<el-input-number v-model="addHoldForm.semiMinor" :min="50" :max="50000" style="width:100%" />
</el-form-item>
<el-form-item label="长轴方位(度)">
<el-input-number v-model="addHoldForm.headingDeg" :min="-180" :max="180" style="width:100%" />
</el-form-item>
</template>
<el-form-item label="盘旋方向">
<el-radio-group v-model="addHoldForm.clockwise">
<el-radio :label="true">顺时针</el-radio>
<el-radio :label="false">逆时针</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="计划离开(K时)">
<el-input
v-model.number="addHoldForm.startTimeMinutes"
type="number"
placeholder="正数=K+多少分钟,负数=K-多少分钟,留空用下一航点时间"
/>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="showAddHoldDialog = false">取消</el-button>
<el-button type="primary" @click="saveAddHold">确定添加</el-button>
</span>
</el-dialog>
</div>
<!-- 在线成员弹窗 -->
@ -333,7 +382,7 @@ import BottomLeftPanel from './BottomLeftPanel'
import TopHeader from './TopHeader'
import { listScenario,addScenario,delScenario} from "@/api/system/scenario";
import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes } from "@/api/system/routes";
import { updateWaypoints } from "@/api/system/waypoints";
import { updateWaypoints, addWaypoints, delWaypoints } from "@/api/system/waypoints";
import { listLib,addLib,delLib} from "@/api/system/lib";
import { getRooms, updateRooms } from "@/api/system/rooms";
import { getMenuConfig, saveMenuConfig } from "@/api/system/userMenuConfig";
@ -518,6 +567,12 @@ export default {
currentTime: 'K+00:00:00',
deductionMinutesFromK: 0,
deductionWarnings: [],
deductionEarlyArrivalByRoute: {}, // routeId -> earlyArrivalLegs
showAddHoldDialog: false,
addHoldContext: null, // { routeId, routeName, legIndex, fromName, toName }
addHoldForm: { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: null },
missionDrawingActive: false,
missionDrawingPointsCount: 0,
isPlaying: false,
playbackSpeed: 1,
playbackInterval: null,
@ -569,6 +624,21 @@ export default {
kTimeDisplay() {
if (!this.roomDetail || !this.roomDetail.kAnchorTime) return '';
return this.formatKTimeForPicker(this.roomDetail.kAnchorTime) || '';
},
hasEarlyArrivalLegs() {
return this.activeRouteIds.some(rid => (this.deductionEarlyArrivalByRoute[rid] || []).length > 0);
},
addHoldDialogTitle() {
if (!this.addHoldContext) return '添加盘旋';
if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋';
if (this.addHoldContext.mode === 'drawing_first') return '先画盘旋再画航线';
return '在此航段添加盘旋';
},
addHoldDialogTip() {
if (!this.addHoldContext) return '';
if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋,保存航线时生效。';
if (this.addHoldContext.mode === 'drawing_first') return '设置盘旋参数后,将进入航线绘制:第一个点击为盘旋中心,第二个点击为下一航点。';
return `${this.addHoldContext.fromName}${this.addHoldContext.toName} 之间添加盘旋,到计划时间后沿切线飞往下一航点(原「下一格」航点将被移除)。`;
}
},
mounted() {
@ -835,6 +905,8 @@ export default {
if (route && route.waypoints && route.waypoints.length > 0) {
this.$refs.cesiumMap.removeRouteById(updatedRoute.id);
this.$refs.cesiumMap.renderRouteWaypoints(route.waypoints, updatedRoute.id, route.platformId, route.platform, routeStyle);
//
this.$nextTick(() => this.updateDeductionPositions());
}
}
} else {
@ -856,6 +928,8 @@ export default {
}
// Cesium
if (this.$refs.cesiumMap) {
this.missionDrawingActive = true;
this.missionDrawingPointsCount = 0;
this.$refs.cesiumMap.startMissionRouteDrawing();
this.$message.success(`${plan.name}开启航线规划`);
}
@ -944,6 +1018,7 @@ export default {
}
},
handleMapDrawComplete(points) {
this.missionDrawingActive = false;
if (!points || points.length < 2) {
this.$message.error('航点太少,无法生成航线');
this.drawDom = false;
@ -952,6 +1027,19 @@ export default {
this.tempMapPoints = points; //
this.showNameDialog = true; //
},
openAddHoldDuringDrawing() {
this.addHoldContext = { mode: 'drawing' };
this.addHoldForm = { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 };
this.showAddHoldDialog = true;
},
openAddHoldThenCreateRoute(plan) {
if (!plan || !plan.id) return;
this.addHoldContext = { mode: 'drawing_first', plan };
this.addHoldForm = { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 };
this.showAddHoldDialog = true;
},
/** 弹窗点击“确定”:正式将数据保存到后端数据库 */
async confirmSaveNewRoute() {
// 1.
@ -965,15 +1053,17 @@ export default {
return;
}
// 2.
// 2. pointTypeholdParams
const finalWaypoints = this.tempMapPoints.map((p, index) => ({
name: `WP${index + 1}`,
name: p.name || `WP${index + 1}`,
lat: p.lat,
lng: p.lng,
alt: 5000.0,
speed: 800.0,
startTime: 'K+00:00:00',
turnAngle: 0.0
alt: p.alt != null ? p.alt : 5000.0,
speed: p.speed != null ? p.speed : 800.0,
startTime: p.startTime || 'K+00:00:00',
turnAngle: p.turnAngle != null ? p.turnAngle : 0.0,
...(p.pointType && { pointType: p.pointType }),
...(p.holdParams != null && { holdParams: typeof p.holdParams === 'string' ? p.holdParams : JSON.stringify(p.holdParams) })
}));
const routeData = {
@ -1068,26 +1158,33 @@ export default {
: 'K+00:00:00',
turnAngle: updatedWaypoint.turnAngle
};
if (updatedWaypoint.pointType != null) payload.pointType = updatedWaypoint.pointType;
if (updatedWaypoint.holdParams != null) payload.holdParams = updatedWaypoint.holdParams;
const response = await updateWaypoints(payload);
if (response.code === 200) {
const index = this.selectedRouteDetails.waypoints.findIndex(p => p.id === updatedWaypoint.id);
if (index !== -1) {
// payload startTime
this.selectedRouteDetails.waypoints.splice(index, 1, { ...updatedWaypoint, ...payload });
//
const merged = { ...updatedWaypoint, ...payload };
const routeInList = this.routes.find(r => r.id === this.selectedRouteDetails.id);
if (routeInList && routeInList.waypoints) {
const idxInList = routeInList.waypoints.findIndex(p => p.id === updatedWaypoint.id);
if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged);
}
if (this.$refs.cesiumMap) {
//
this.$refs.cesiumMap.updateWaypointGraphicById(updatedWaypoint.id, updatedWaypoint.name);
// 线
this.$refs.cesiumMap.renderRouteWaypoints(
this.selectedRouteDetails.waypoints,
this.selectedRouteDetails.id,
this.selectedRouteDetails.platformId,
this.selectedRouteDetails.platform
this.selectedRouteDetails.platform,
this.parseRouteStyle(this.selectedRouteDetails.attributes)
);
}
this.showWaypointDialog = false;
this.$message.success('航点信息已持久化至数据库');
this.$nextTick(() => this.updateDeductionPositions());
}
} else {
// code 200catch
@ -1114,7 +1211,7 @@ export default {
return this.activeRouteIds && this.activeRouteIds.length > 0;
},
updateCombatTime() {
// 线 = updateTimeFromProgress
// 线 deductionMinutesFromK线 currentTime updateTimeFromProgress
if (this.hasDeductionRange()) {
const sign = this.deductionMinutesFromK >= 0 ? '+' : '-';
const absMin = Math.abs(Math.floor(this.deductionMinutesFromK));
@ -1123,8 +1220,8 @@ export default {
this.combatTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
return;
}
// 线
this.combatTime = 'K+00:00:00';
// 线 currentTime
this.combatTime = this.currentTime || 'K+00:00:00';
},
/** 将作战时间字符串(如 K+01:30:00)解析为相对 K 的分钟数 */
combatTimeToMinutes(str) {
@ -1737,10 +1834,8 @@ export default {
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`;
//
if (this.hasDeductionRange()) {
// 线
this.combatTime = this.currentTime;
}
this.updateDeductionPositions();
},
@ -1775,6 +1870,62 @@ export default {
const min = parseInt(m[3], 10);
return sign * (h * 60 + min);
},
/** 将相对 K 的分钟数转为 startTime 字符串(如 K+01:00、K-00:30) */
minutesToStartTime(minutes) {
const m = Math.floor(Number(minutes));
if (!Number.isFinite(m)) return 'K+00:00';
const sign = m >= 0 ? '+' : '-';
const abs = Math.abs(m);
const h = Math.floor(abs / 60);
const min = abs % 60;
return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
},
isHoldWaypoint(wp) {
const t = (wp && wp.pointType) || (wp && wp.point_type) || 'normal';
return t === 'hold_circle' || t === 'hold_ellipse';
},
parseHoldParams(wp) {
const raw = (wp && wp.holdParams) || (wp && wp.hold_params);
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 };
} catch (e) {
return null;
}
},
/** 路径片段总距离(米) */
pathSliceDistance(pathSlice) {
if (!pathSlice || pathSlice.length < 2) return 0;
let d = 0;
for (let i = 1; i < pathSlice.length; i++) d += this.segmentDistance(pathSlice[i - 1], pathSlice[i]);
return d;
},
/** 圆周上按角度取点:圆心 lng/lat/alt,半径米。angleRad 为从北顺时针的角度弧度,0=北 */
positionOnCircle(centerLng, centerLat, centerAlt, radiusM, angleRad) {
const R = 6371000;
const latRad = (centerLat * Math.PI) / 180;
const dNorth = radiusM * Math.cos(angleRad);
const dEast = radiusM * Math.sin(angleRad);
const dLat = (dNorth / R) * (180 / Math.PI);
const dLng = (dEast / (R * Math.cos(latRad))) * (180 / Math.PI);
return {
lng: centerLng + dLng,
lat: centerLat + dLat,
alt: centerAlt != null ? centerAlt : 0
};
},
/** 从圆心到某点的角度(弧度,从北顺时针),用于盘旋入口基准角 */
angleFromCenterToPoint(centerLng, centerLat, pointLng, pointLat) {
const R = 6371000;
const latRad = (centerLat * Math.PI) / 180;
const dNorth = (pointLat - centerLat) * (R * Math.PI / 180);
const dEast = (pointLng - centerLng) * (R * Math.cos(latRad) * Math.PI / 180);
return Math.atan2(dEast, dNorth);
},
/** 两航点间近似距离(米),含高度差 */
segmentDistance(wp1, wp2) {
@ -1791,26 +1942,28 @@ export default {
},
/**
* 按速度与计划时间构建航线时间轴含飞行段与提前到达则等待的等待段并做航段校验
* 若所有航点相对 K 时相同则在 [globalMin, globalMax] 内按航点顺序均匀分布避免闪现
* 按速度与计划时间构建航线时间轴含飞行段盘旋段提前到达则等待的等待段
* pathData 可选{ path, segmentEndIndices, holdArcRanges } getRoutePathWithSegmentIndices 提供用于输出 hold
*/
buildRouteTimeline(waypoints, globalMin, globalMax) {
buildRouteTimeline(waypoints, globalMin, globalMax, pathData) {
const warnings = [];
if (!waypoints || waypoints.length === 0) return { segments: [], warnings };
const points = waypoints.map(wp => ({
const points = waypoints.map((wp, idx) => ({
lng: parseFloat(wp.lng),
lat: parseFloat(wp.lat),
alt: Number(wp.alt) || 0,
minutes: this.waypointStartTimeToMinutes(wp.startTime),
speed: Number(wp.speed) || 800
speed: Number(wp.speed) || 800,
isHold: this.isHoldWaypoint(wp)
}));
const hasHold = points.some(p => p.isHold);
const allSame = points.every(p => p.minutes === points[0].minutes);
if (allSame && points.length > 1) {
if (allSame && points.length > 1 && !hasHold) {
const span = Math.max(globalMax - globalMin, 1);
points.forEach((p, i) => {
p.minutes = globalMin + (span * i) / (points.length - 1);
});
} else {
} else if (!hasHold) {
points.sort((a, b) => a.minutes - b.minutes);
}
if (points.length === 1) {
@ -1823,7 +1976,61 @@ export default {
}
const effectiveTime = [points[0].minutes];
const segments = [];
const path = pathData && pathData.path;
const segmentEndIndices = pathData && pathData.segmentEndIndices;
const holdArcRanges = pathData && pathData.holdArcRanges || {};
for (let i = 0; i < points.length - 1; i++) {
if (this.isHoldWaypoint(waypoints[i + 1]) && path && segmentEndIndices && holdArcRanges[i]) {
const range = holdArcRanges[i];
const startIdx = i === 0 ? 0 : segmentEndIndices[i - 1] + 1;
const toEntrySlice = path.slice(startIdx, range.start + 1);
const holdPathSlice = path.slice(range.start, range.end + 1);
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 holdEndTime = points[i + 1].minutes;
const distExitToNext = this.pathSliceDistance(toNextSlice);
const travelExitMin = (distExitToNext / 1000) * (60 / speedKmh);
const arrivalNext = holdEndTime + travelExitMin;
effectiveTime[i + 1] = holdEndTime;
if (i + 2 < points.length) effectiveTime[i + 2] = arrivalNext;
const posCur = { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt };
const entryPos = toEntrySlice.length ? toEntrySlice[toEntrySlice.length - 1] : posCur;
const exitPos = holdPathSlice.length ? holdPathSlice[holdPathSlice.length - 1] : entryPos;
const holdDurationMin = holdEndTime - arrivalEntry;
const holdWp = waypoints[i + 1];
const holdParams = this.parseHoldParams(holdWp);
const holdCenter = holdWp ? { lng: parseFloat(holdWp.lng), lat: parseFloat(holdWp.lat), alt: Number(holdWp.alt) || 0 } : null;
const holdRadius = holdParams && holdParams.radius != null ? holdParams.radius : null;
const holdClockwise = holdParams && holdParams.clockwise !== false;
const holdCircumference = holdRadius != null ? 2 * Math.PI * holdRadius : null;
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: arrivalEntry,
endTime: holdEndTime,
startPos: entryPos,
endPos: exitPos,
type: 'hold',
legIndex: i,
holdPath: holdPathSlice,
holdDurationMin,
speedKmh: points[i].speed || 800,
holdCenter,
holdRadius,
holdCircumference,
holdClockwise,
holdEntryAngle
});
segments.push({ startTime: holdEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice });
i++;
continue;
}
const dist = this.segmentDistance(points[i], points[i + 1]);
const speedKmh = points[i].speed || 800;
const travelMin = (dist / 1000) * (60 / speedKmh);
@ -1847,7 +2054,19 @@ export default {
segments.push({ startTime: actualArrival, endTime: effectiveTime[i + 1], startPos: posNext, endPos: posNext, type: 'wait', legIndex: i });
}
}
return { segments, warnings };
const earlyArrivalLegs = [];
for (let i = 0; i < points.length - 1; i++) {
if (this.isHoldWaypoint(waypoints[i + 1])) continue;
const dist = this.segmentDistance(points[i], points[i + 1]);
const speedKmh = points[i].speed || 800;
const travelMin = (dist / 1000) * (60 / speedKmh);
const actualArrival = effectiveTime[i] + travelMin;
const scheduled = points[i + 1].minutes;
if (travelMin > 0 && scheduled - points[i].minutes > 0 && actualArrival < scheduled - 0.5) {
earlyArrivalLegs.push({ legIndex: i, scheduled, actualArrival, fromName: waypoints[i].name, toName: waypoints[i + 1].name });
}
}
return { segments, warnings, earlyArrivalLegs };
},
/** 从路径片段中按比例 t(0~1)取点:按弧长比例插值 */
@ -1875,31 +2094,52 @@ export default {
};
},
/** 从时间轴中取当前推演时间对应的位置;若有 path/segmentEndIndices 则沿带转弯弧的路径插值 */
/** 从时间轴中取当前推演时间对应的位置;支持 fly/wait/hold,hold 沿 holdPath 弧长插值 */
getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices) {
if (!segments || segments.length === 0) return null;
if (minutesFromK <= segments[0].startTime) return segments[0].startPos;
const last = segments[segments.length - 1];
if (minutesFromK >= last.endTime) {
// 线线
if (last.type === 'wait' && path && segmentEndIndices && last.legIndex != null && last.legIndex < segmentEndIndices.length && path[segmentEndIndices[last.legIndex]]) {
return path[segmentEndIndices[last.legIndex]];
}
if (last.type === 'hold' && last.holdPath && last.holdPath.length) return last.holdPath[last.holdPath.length - 1];
return last.endPos;
}
for (let i = 0; i < segments.length; i++) {
const s = segments[i];
if (minutesFromK < s.endTime) {
const t = (minutesFromK - s.startTime) / (s.endTime - s.startTime);
const t = Math.max(0, Math.min(1, (minutesFromK - s.startTime) / (s.endTime - s.startTime)));
if (s.type === 'wait') {
// 线线
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) {
const endIdx = segmentEndIndices[s.legIndex];
if (path[endIdx]) return path[endIdx];
}
return s.startPos;
}
// 沿线线
if (s.type === 'hold' && s.holdPath && s.holdPath.length) {
const durationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime);
const speedKmh = s.speedKmh != null ? s.speedKmh : 800;
const totalHoldDistM = (speedKmh * (durationMin / 60)) * 1000;
if (s.holdCircumference != null && s.holdCircumference > 0 && s.holdCenter && s.holdRadius != null) {
const currentDistM = t * totalHoldDistM;
const distOnLap = currentDistM % s.holdCircumference;
const angleRad = (distOnLap / s.holdCircumference) * (2 * Math.PI);
const signedAngle = s.holdClockwise ? -angleRad : angleRad;
const entryAngle = s.holdEntryAngle != null ? s.holdEntryAngle : 0;
const angle = entryAngle + signedAngle;
return this.positionOnCircle(s.holdCenter.lng, s.holdCenter.lat, s.holdCenter.alt, s.holdRadius, angle);
}
const holdPathLen = this.pathSliceDistance(s.holdPath);
if (holdPathLen <= 0) return this.getPositionAlongPathSlice(s.holdPath, t);
const currentDistM = t * totalHoldDistM;
const positionOnLap = currentDistM % holdPathLen;
const tLap = holdPathLen > 0 ? positionOnLap / holdPathLen : 0;
return this.getPositionAlongPathSlice(s.holdPath, tLap);
}
if (s.type === 'fly' && s.pathSlice && s.pathSlice.length) {
return this.getPositionAlongPathSlice(s.pathSlice, t);
}
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) {
const startIdx = s.legIndex === 0 ? 0 : segmentEndIndices[s.legIndex - 1];
const endIdx = segmentEndIndices[s.legIndex];
@ -1916,24 +2156,24 @@ export default {
return last.endPos;
},
/** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧路径运动;返回 { position, nextPosition, previousPosition, warnings },用于计算机头朝向 */
/** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧/盘旋弧路径运动;返回 { position, nextPosition, previousPosition, warnings } */
getPositionAtMinutesFromK(waypoints, minutesFromK, globalMin, globalMax) {
if (!waypoints || waypoints.length === 0) return { position: null, nextPosition: null, previousPosition: null, warnings: [] };
const { segments, warnings } = this.buildRouteTimeline(waypoints, globalMin, globalMax);
let path = null;
let segmentEndIndices = null;
let pathData = null;
if (this.$refs.cesiumMap && this.$refs.cesiumMap.getRoutePathWithSegmentIndices) {
const ret = this.$refs.cesiumMap.getRoutePathWithSegmentIndices(waypoints);
if (ret.path && ret.path.length > 0 && ret.segmentEndIndices && ret.segmentEndIndices.length > 0) {
path = ret.path;
segmentEndIndices = ret.segmentEndIndices;
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} };
}
}
const { segments, warnings, earlyArrivalLegs } = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData);
const path = pathData ? pathData.path : null;
const segmentEndIndices = pathData ? pathData.segmentEndIndices : null;
const position = this.getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices);
const stepMin = 1 / 60;
const nextPosition = this.getPositionFromTimeline(segments, minutesFromK + stepMin, path, segmentEndIndices);
const previousPosition = this.getPositionFromTimeline(segments, minutesFromK - stepMin, path, segmentEndIndices);
return { position, nextPosition, previousPosition, warnings };
return { position, nextPosition, previousPosition, warnings, earlyArrivalLegs: earlyArrivalLegs || [] };
},
/** 仅根据当前展示的航线(activeRouteIds)更新平台图标位置,并汇总航段提示 */
@ -1945,13 +2185,120 @@ export default {
this.activeRouteIds.forEach(routeId => {
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.waypoints || route.waypoints.length === 0) return;
const { position, nextPosition, previousPosition, warnings } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes);
const { position, nextPosition, previousPosition, warnings, earlyArrivalLegs } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes);
if (warnings && warnings.length) allWarnings.push(...warnings);
if (position) this.$refs.cesiumMap.updatePlatformPosition(routeId, position, nextPosition || previousPosition);
this.deductionEarlyArrivalByRoute[routeId] = earlyArrivalLegs || [];
});
this.deductionWarnings = [...new Set(allWarnings)];
},
openAddHoldFromFirstEarly() {
for (const routeId of this.activeRouteIds) {
const legs = this.deductionEarlyArrivalByRoute[routeId] || [];
if (legs.length === 0) continue;
const route = this.routes.find(r => r.id === routeId);
const leg = legs[0];
const waypoints = route && route.waypoints ? route.waypoints : [];
const nextWp = waypoints[leg.legIndex + 1];
this.addHoldContext = {
routeId,
routeName: route ? route.name : '',
legIndex: leg.legIndex,
fromName: leg.fromName,
toName: leg.toName
};
const defaultStart = nextWp && nextWp.startTime ? nextWp.startTime : 'K+01:00';
this.addHoldForm.startTime = defaultStart;
this.addHoldForm.startTimeMinutes = this.waypointStartTimeToMinutes(defaultStart);
this.showAddHoldDialog = true;
return;
}
},
async saveAddHold() {
if (!this.addHoldContext) return;
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 };
if (this.$refs.cesiumMap && this.$refs.cesiumMap.insertHoldBetweenLastTwo) {
this.$refs.cesiumMap.insertHoldBetweenLastTwo(holdParams);
}
this.showAddHoldDialog = false;
this.addHoldContext = null;
this.$message.success('已插入盘旋,继续点选航点后右键结束保存');
return;
}
if (this.addHoldContext.mode === 'drawing_first') {
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 };
this.showAddHoldDialog = false;
const plan = this.addHoldContext.plan;
this.addHoldContext = null;
this.createRoute(plan);
this.$nextTick(() => {
if (this.$refs.cesiumMap && this.$refs.cesiumMap.setInitialHoldParams) {
this.$refs.cesiumMap.setInitialHoldParams(holdParams);
}
});
this.$message.success('请先点击盘旋中心,再点击下一航点,然后继续绘制或右键结束');
return;
}
const { routeId, legIndex } = this.addHoldContext;
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.waypoints || route.waypoints.length < 2) {
this.$message.warning('航线数据异常');
return;
}
const waypoints = route.waypoints;
const prevWp = waypoints[legIndex];
const nextWp = waypoints[legIndex + 1];
const newSeq = (prevWp.seq != null ? Number(prevWp.seq) : legIndex + 1) + 1;
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 };
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');
try {
await addWaypoints({
routeId,
name: 'HOLD',
seq: newSeq,
lat: nextWp.lat,
lng: nextWp.lng,
alt: nextWp.alt != null ? nextWp.alt : prevWp.alt,
speed: prevWp.speed || 800,
startTime,
pointType: this.addHoldForm.holdType,
holdParams: JSON.stringify(holdParams)
});
await delWaypoints(nextWp.id);
for (let i = legIndex + 2; i < waypoints.length; i++) {
const w = waypoints[i];
if (w.id) {
await updateWaypoints({ ...w, seq: baseSeq + (i - legIndex) });
}
}
this.showAddHoldDialog = false;
this.addHoldContext = null;
await this.getList();
const updated = this.routes.find(r => r.id === routeId);
if (updated && updated.waypoints && this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap) {
this.$refs.cesiumMap.removeRouteById(routeId);
this.$refs.cesiumMap.renderRouteWaypoints(updated.waypoints, routeId, updated.platformId, updated.platform, this.parseRouteStyle(updated.attributes));
this.$nextTick(() => this.updateDeductionPositions());
}
this.$message.success('已添加盘旋航点');
} catch (e) {
this.$message.error(e.msg || '添加盘旋失败');
console.error(e);
}
},
//
play() {
this.$message.success('推演开始');

81
ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue

@ -34,7 +34,7 @@
></el-input-number>
</el-form-item>
<el-form-item label="转弯坡度" prop="turnAngle">
<el-form-item v-if="!isHoldWaypoint" label="转弯坡度" prop="turnAngle">
<el-input-number
v-model="formData.turnAngle"
controls-position="right"
@ -46,6 +46,34 @@
首尾航点坡度已锁定为 0不可编辑
</div>
</el-form-item>
<template v-if="isHoldWaypoint">
<el-form-item label="盘旋类型">
<el-radio-group v-model="formData.pointType">
<el-radio label="hold_circle">圆形</el-radio>
<el-radio label="hold_ellipse">椭圆</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="formData.pointType === 'hold_circle'" label="半径(米)">
<el-input-number v-model="formData.holdRadius" :min="100" :max="50000" style="width:100%" />
</el-form-item>
<template v-if="formData.pointType === 'hold_ellipse'">
<el-form-item label="长半轴(米)">
<el-input-number v-model="formData.holdSemiMajor" :min="100" :max="50000" style="width:100%" />
</el-form-item>
<el-form-item label="短半轴(米)">
<el-input-number v-model="formData.holdSemiMinor" :min="50" :max="50000" style="width:100%" />
</el-form-item>
<el-form-item label="长轴方位(度)">
<el-input-number v-model="formData.holdHeadingDeg" :min="-180" :max="180" style="width:100%" />
</el-form-item>
</template>
<el-form-item label="盘旋方向">
<el-radio-group v-model="formData.holdClockwise">
<el-radio :label="true">顺时针</el-radio>
<el-radio :label="false">逆时针</el-radio>
</el-radio-group>
</el-form-item>
</template>
<el-form-item label="相对 K 时(分钟)" prop="minutesFromK">
<el-input-number
@ -103,7 +131,13 @@ export default {
minutesFromK: 0,
currentIndex: -1,
totalPoints: 0,
isBankDisabled: false
isBankDisabled: false,
pointType: 'normal',
holdRadius: 500,
holdSemiMajor: 500,
holdSemiMinor: 300,
holdHeadingDeg: 0,
holdClockwise: true
},
rules: {
name: [
@ -124,6 +158,12 @@ export default {
}
};
},
computed: {
isHoldWaypoint() {
const t = (this.waypoint && (this.waypoint.pointType || this.waypoint.point_type)) || (this.formData && this.formData.pointType) || 'normal';
return t === 'hold_circle' || t === 'hold_ellipse';
}
},
watch: {
value(newVal) {
if (newVal && this.waypoint) {
@ -142,6 +182,19 @@ export default {
const total = this.waypoint.totalPoints || 0;
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;
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;
}
} catch (e) {}
this.formData = {
name: this.waypoint.name || '',
alt: this.waypoint.alt !== undefined && this.waypoint.alt !== null ? Number(this.waypoint.alt) : 0,
@ -150,7 +203,13 @@ export default {
currentIndex: index,
totalPoints: total,
isBankDisabled: locked,
turnAngle: locked ? 0 : (Number(this.waypoint.turnAngle) || 0)
turnAngle: locked ? 0 : (Number(this.waypoint.turnAngle) || 0),
pointType: pt,
holdRadius,
holdSemiMajor,
holdSemiMinor,
holdHeadingDeg,
holdClockwise
};
this.$nextTick(() => {
@ -167,11 +226,19 @@ export default {
if (valid) {
const { minutesFromK, ...rest } = this.formData;
const startTimeStr = this.minutesToStartTime(minutesFromK);
this.$emit('save', {
...this.waypoint,
...rest,
startTime: startTimeStr
const payload = { ...this.waypoint, ...rest, startTime: startTimeStr };
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
});
}
this.$emit('save', payload);
this.closeDialog();
}
});

Loading…
Cancel
Save