Skip to content

Commit 7956356

Browse files
authored
Start adding mission elevation graph (#1118)
* Start adding mission elevation graph * Address copilot review comments
1 parent ef23994 commit 7956356

10 files changed

Lines changed: 626 additions & 1 deletion

File tree

gcs/electron/main.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import registerAboutIPC, {
2929
import registerEkfStatusIPC, {
3030
destroyEkfStatusWindow,
3131
} from "./modules/ekfStatusWindow"
32+
import registerElevationGraphIPC, {
33+
destroyElevationGraphWindow,
34+
} from "./modules/elevationGraphWindow"
3235
import registerFFmpegBinaryIPC from "./modules/ffmpegBinary"
3336
import registerFlaParamsIPC, {
3437
destroyFlaParamsWindow,
@@ -330,6 +333,7 @@ function createWindow() {
330333
registerAboutIPC()
331334
registerLinkStatsIPC()
332335
registerEkfStatusIPC()
336+
registerElevationGraphIPC()
333337
registerVibeStatusIPC()
334338
registerFFmpegBinaryIPC()
335339
registerRTSPStreamIPC(win)
@@ -520,6 +524,7 @@ function closeWindows() {
520524
destroyAboutWindow()
521525
destroyLinkStatsWindow()
522526
destroyEkfStatusWindow()
527+
destroyElevationGraphWindow()
523528
destroyVibeStatusWindow()
524529
cleanupAllRTSPStreams()
525530
destroyFlaParamsWindow()
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { BrowserWindow, ipcMain } from "electron"
2+
import path from "path"
3+
import { getCenteredWindowPosition } from "../utils/windowUtils"
4+
5+
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]
6+
7+
let elevationGraphWin: BrowserWindow | null = null
8+
let lastElevationGraphPayload: unknown = null
9+
10+
function sendElevationPayload() {
11+
if (!elevationGraphWin) return
12+
if (elevationGraphWin.isDestroyed()) return
13+
if (elevationGraphWin.webContents.isDestroyed()) return
14+
if (lastElevationGraphPayload === null) return
15+
16+
elevationGraphWin.webContents.send(
17+
"app:send-elevation-graph",
18+
lastElevationGraphPayload,
19+
)
20+
}
21+
22+
export function openElevationGraphWindow(parentWindow?: BrowserWindow) {
23+
if (elevationGraphWin === null) {
24+
const windowOptions: Electron.BrowserWindowConstructorOptions = {
25+
width: 760,
26+
height: 420,
27+
frame: true,
28+
icon: path.join(process.env.VITE_PUBLIC, "app_icon.ico"),
29+
show: false,
30+
title: "Elevation Graph",
31+
webPreferences: {
32+
preload: path.join(__dirname, "preload.js"),
33+
contextIsolation: true,
34+
},
35+
fullscreen: false,
36+
fullscreenable: false,
37+
alwaysOnTop: true,
38+
}
39+
40+
const centeredPosition = getCenteredWindowPosition(
41+
parentWindow,
42+
windowOptions.width!,
43+
windowOptions.height!,
44+
)
45+
if (centeredPosition) {
46+
windowOptions.x = centeredPosition.x
47+
windowOptions.y = centeredPosition.y
48+
}
49+
50+
elevationGraphWin = new BrowserWindow(windowOptions)
51+
elevationGraphWin.once("closed", () => {
52+
elevationGraphWin = null
53+
})
54+
}
55+
56+
if (VITE_DEV_SERVER_URL) {
57+
elevationGraphWin?.loadURL(VITE_DEV_SERVER_URL + "elevationGraph.html")
58+
} else {
59+
elevationGraphWin?.loadFile(
60+
path.join(process.env.DIST, "elevationGraph.html"),
61+
)
62+
}
63+
64+
elevationGraphWin.setMenuBarVisibility(false)
65+
elevationGraphWin.show()
66+
}
67+
68+
export function closeElevationGraphWindow() {
69+
destroyElevationGraphWindow()
70+
}
71+
72+
export function destroyElevationGraphWindow() {
73+
elevationGraphWin?.close()
74+
elevationGraphWin = null
75+
}
76+
77+
export default function registerElevationGraphIPC() {
78+
ipcMain.removeHandler("app:open-elevation-graph-window")
79+
ipcMain.removeHandler("app:close-elevation-graph-window")
80+
ipcMain.removeHandler("app:update-elevation-graph")
81+
82+
ipcMain.removeAllListeners("app:elevation-graph:ready")
83+
ipcMain.on("app:elevation-graph:ready", (event) => {
84+
if (!elevationGraphWin) return
85+
if (event.sender.id !== elevationGraphWin.webContents.id) return
86+
sendElevationPayload()
87+
})
88+
89+
ipcMain.handle("app:open-elevation-graph-window", (event) => {
90+
const parentWindow = BrowserWindow.fromWebContents(event.sender)
91+
openElevationGraphWindow(parentWindow || undefined)
92+
})
93+
94+
ipcMain.handle("app:close-elevation-graph-window", () =>
95+
closeElevationGraphWindow(),
96+
)
97+
98+
ipcMain.handle("app:update-elevation-graph", (_event, profileData) => {
99+
lastElevationGraphPayload = profileData
100+
sendElevationPayload()
101+
})
102+
}

gcs/electron/preload.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ const ALLOWED_INVOKE_CHANNELS = [
3535
"window:select-file-in-explorer",
3636
"app:update-ekf-status",
3737
"app:open-ekf-status-window",
38+
"app:open-elevation-graph-window",
39+
"app:close-elevation-graph-window",
40+
"app:update-elevation-graph",
3841
"app:update-vibe-status",
3942
"app:open-vibe-status-window",
4043
"params:load-params-from-file",
@@ -59,6 +62,7 @@ const ALLOWED_SEND_CHANNELS = [
5962
// drone state updates (connectedToDrone, isArmed, isFlying)
6063
"app:drone-state",
6164
"app:graph-window:ready",
65+
"app:elevation-graph:ready",
6266
]
6367

6468
const ALLOWED_ON_CHANNELS = [
@@ -68,6 +72,7 @@ const ALLOWED_ON_CHANNELS = [
6872
"app:send-link-stats",
6973
"fla:log-parse-progress",
7074
"app:send-ekf-status",
75+
"app:send-elevation-graph",
7176
"app:send-vibe-status",
7277
"settings:open",
7378
"mavlink-forwarding:open",

gcs/elevationGraph.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/logo_dark_icon.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/elevationGraph.jsx"></script>
11+
</body>
12+
</html>
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import {
2+
CategoryScale,
3+
Chart as ChartJS,
4+
Legend,
5+
LineElement,
6+
LinearScale,
7+
PointElement,
8+
Tooltip,
9+
} from "chart.js"
10+
import { useEffect, useMemo, useState } from "react"
11+
import { Line } from "react-chartjs-2"
12+
13+
const waypointLabelPlugin = {
14+
id: "waypointLabelPlugin",
15+
afterDatasetsDraw(chart) {
16+
const { ctx } = chart
17+
const datasetMeta = chart.getDatasetMeta(0)
18+
const dataset = chart.data?.datasets?.[0]
19+
20+
if (!datasetMeta || !dataset?.data) return
21+
22+
ctx.save()
23+
ctx.font = "11px sans-serif"
24+
ctx.fillStyle = "#e2e8f0"
25+
ctx.textAlign = "left"
26+
ctx.textBaseline = "bottom"
27+
28+
datasetMeta.data.forEach((element, index) => {
29+
const point = dataset.data[index]
30+
if (!point) return
31+
32+
const label =
33+
point.waypointLabel ??
34+
(Number.isFinite(point.waypointSeq)
35+
? `${point.waypointSeq}`
36+
: undefined)
37+
38+
if (!label) return
39+
ctx.fillText(label, element.x + 6, element.y - 4)
40+
})
41+
42+
ctx.restore()
43+
},
44+
}
45+
46+
ChartJS.register(
47+
CategoryScale,
48+
LinearScale,
49+
PointElement,
50+
LineElement,
51+
Tooltip,
52+
Legend,
53+
waypointLabelPlugin,
54+
)
55+
56+
const chartOptions = {
57+
responsive: true,
58+
maintainAspectRatio: false,
59+
animation: false,
60+
plugins: {
61+
legend: {
62+
display: true,
63+
position: "top",
64+
labels: {
65+
color: "#e2e8f0",
66+
},
67+
},
68+
tooltip: {
69+
callbacks: {
70+
title: () => "",
71+
label: (ctx) => {
72+
if (ctx.raw?.isHome) {
73+
return `Home: ${ctx.parsed.y.toFixed(2)} m`
74+
}
75+
const seq = ctx.raw?.waypointSeq
76+
return `WP ${seq}: ${ctx.parsed.y.toFixed(2)} m`
77+
},
78+
},
79+
},
80+
},
81+
scales: {
82+
x: {
83+
type: "linear",
84+
title: {
85+
display: true,
86+
text: "Distance (m)",
87+
color: "#e2e8f0",
88+
},
89+
ticks: {
90+
color: "#e2e8f0",
91+
},
92+
grid: {
93+
color: "rgba(148, 163, 184, 0.15)",
94+
},
95+
},
96+
y: {
97+
title: {
98+
display: true,
99+
text: "Altitude (m)",
100+
color: "#e2e8f0",
101+
},
102+
ticks: {
103+
color: "#e2e8f0",
104+
},
105+
grid: {
106+
color: "rgba(148, 163, 184, 0.15)",
107+
},
108+
},
109+
},
110+
elements: {
111+
line: {
112+
borderWidth: 2,
113+
tension: 0,
114+
},
115+
point: {
116+
radius: 4,
117+
hitRadius: 10,
118+
},
119+
},
120+
}
121+
122+
export default function ElevationGraphWindow() {
123+
const [profile, setProfile] = useState({
124+
points: [],
125+
totalDistance: 0,
126+
warnings: [],
127+
})
128+
129+
useEffect(() => {
130+
const handleUpdate = (_event, payload) => {
131+
setProfile(
132+
payload || {
133+
points: [],
134+
totalDistance: 0,
135+
warnings: [],
136+
},
137+
)
138+
}
139+
140+
window.ipcRenderer.on("app:send-elevation-graph", handleUpdate)
141+
window.ipcRenderer.send("app:elevation-graph:ready")
142+
143+
return () => {
144+
window.ipcRenderer.removeListener(
145+
"app:send-elevation-graph",
146+
handleUpdate,
147+
)
148+
}
149+
}, [])
150+
151+
const chartData = useMemo(() => {
152+
const data = profile.points.map((point) => ({
153+
x: point.cumulativeDistance,
154+
y: point.altitude,
155+
waypointSeq: point.seq,
156+
waypointLabel: point.label,
157+
isHome: Boolean(point.isHome),
158+
}))
159+
160+
return {
161+
datasets: [
162+
{
163+
label: "Mission Elevation",
164+
data,
165+
borderColor: "#facc15",
166+
backgroundColor: "rgba(250, 204, 21, 0.35)",
167+
showLine: true,
168+
},
169+
],
170+
}
171+
}, [profile])
172+
173+
return (
174+
<div className="w-full h-full bg-falcongrey-800 text-slate-100 p-4 flex flex-col gap-3">
175+
<div className="flex flex-row justify-between items-center text-sm">
176+
<p className="font-semibold">Mission elevation profile</p>
177+
<p className="text-slate-300">
178+
Total distance: {Number(profile.totalDistance || 0).toFixed(2)} m
179+
</p>
180+
</div>
181+
182+
{profile.warnings?.length > 0 && (
183+
<div className="bg-yellow-950/40 border border-yellow-700 rounded px-3 py-2 text-yellow-200 text-xs">
184+
{profile.warnings.map((warning, idx) => (
185+
<p key={`${warning}-${idx}`}>{warning}</p>
186+
))}
187+
</div>
188+
)}
189+
190+
{profile.points.length === 0 ? (
191+
<div className="h-full flex items-center justify-center text-slate-400 text-sm border border-falcongrey-600 rounded">
192+
No NAV waypoints with altitude available for elevation graph.
193+
</div>
194+
) : (
195+
<div className="flex-1 min-h-0">
196+
<Line options={chartOptions} data={chartData} />
197+
</div>
198+
)}
199+
</div>
200+
)
201+
}

gcs/src/components/missions/missionsMap.jsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ function MapSectionNonMemo({
8383
fenceItems,
8484
rallyItems,
8585
onDragstart,
86+
onOpenElevationGraph,
8687
}) {
8788
// Redux
8889
const dispatch = useDispatch()
@@ -307,6 +308,10 @@ function MapSectionNonMemo({
307308
setMeasureDistanceResult(null)
308309
}
309310

311+
function openElevationGraph() {
312+
onOpenElevationGraph?.()
313+
}
314+
310315
return (
311316
<div className="w-initial h-full" id="map">
312317
<Map
@@ -543,6 +548,9 @@ function MapSectionNonMemo({
543548
<ContextMenuItem onClick={measureDistance}>
544549
<p>Measure distance</p>
545550
</ContextMenuItem>
551+
<ContextMenuItem onClick={openElevationGraph}>
552+
<p>Elevation graph</p>
553+
</ContextMenuItem>
546554
<ContextMenuSubMenuItem title={"POI marker"}>
547555
<ContextMenuItem
548556
onClick={() => {

0 commit comments

Comments
 (0)