I had a quadcopter publishing telemetry over MAVLink and a stubborn preference for not leaving the browser. QGroundControl and Mission Planner are both excellent, but they are native apps, and the thing I actually wanted was a status page the rest of the team could open in a tab. Attitude, position, battery, and a running log of whatever the flight controller was complaining about.
The catch is that a drone publishes attitude at 30 to 50 Hz, and the first React version of this that anyone writes tends to fall over the moment real data arrives. You wire each reading to a piece of state, the state updates a few dozen times a second across five or six components, and React spends its whole frame budget reconciling instead of drawing. The display either lags behind the aircraft or, worse, different instruments end up showing different instants of the same moment.
So I built it with Altara, a set of telemetry components I maintain that keep the high-frequency path off React's render cycle. This post builds the ground control station in two passes. First the entire dashboard in mock mode, with no drone and no rosbridge involved, so you can get the layout and styling right against animated data. Then we point the exact same components at a real MAVROS stack.
What we're building
Four panels, the same four every ground station has:
A primary flight display (PFD), the artificial horizon with airspeed and altitude tapes, a heading scale, and a vertical speed indicator. A moving map showing the aircraft's position and the track it has flown. A battery gauge. And an event log for the messages coming off the flight controller.
We will lay them out in a grid, get all four animating, and only then think about where the numbers come from.
Setup
The PFD lives in the aerospace package and the map, gauge, and log live in core, so:
npm install @altara/core @altara/aerospace
The map renders with Leaflet, which Altara treats as an optional peer dependency and loads on mount. If you skip it the map will throw a setup hint at you, so install it now even though we are still in mock mode:
npm install leaflet@^1.9.4 react-leaflet@^4.2.1
The smallest thing that moves
Before the full grid, here is the PFD and the map side by side, animating themselves with no data plumbing at all:
import { PrimaryFlightDisplay } from '@altara/aerospace';
import { LiveMap } from '@altara/core';
export function FlightDeck() {
return (
<div style={{ display: 'flex', gap: 16 }}>
<PrimaryFlightDisplay mockMode size="md" showFlightDirector />
<LiveMap mockMode />
</div>
);
}
That is the whole thing. The mockMode flag on each component switches on a built-in generator, so there is no provider to set up and nothing to feed them.
What you see is worth describing, because it is more deliberate than random jitter. The PFD runs a gentle cruise profile. The horizon banks left and right through about 18 degrees on a slow cycle, the nose pitches up and down a few degrees out of phase with the bank so it reads as a coordinated turn rather than a metronome, the heading scale sweeps slowly around east, and the airspeed and altitude tapes drift around 120 knots and 4500 feet. The vertical speed needle leads the altitude changes correctly, because it is driven by the rate of the same wave. With showFlightDirector on, the magenta guidance bars lag the aircraft slightly, so they look like something the pilot is chasing instead of sitting dead center. It reads as an aircraft loafing along in cruise, which is exactly the point. You want to develop against motion that looks plausible.
The map, meanwhile, flies a slow circle around a fixed point and turns its nose to follow the track, leaving a trail behind it.
The full dashboard, still faked
Now the real layout. Four panels in a grid, every one of them in mock mode except the log, which works a little differently:
import { LiveMap, Gauge, EventLog, type EventLogEntry } from '@altara/core';
import { PrimaryFlightDisplay } from '@altara/aerospace';
const demoEvents: EventLogEntry[] = [
{ timestamp: Date.now() - 9000, severity: 'info', message: 'EKF using GPS' },
{ timestamp: Date.now() - 6000, severity: 'info', message: 'Armed' },
{ timestamp: Date.now() - 3000, severity: 'warn', message: 'Wind 11 m/s, approaching limit' },
{ timestamp: Date.now() - 1000, severity: 'info', message: 'Waypoint 3 reached' },
];
export function GroundControlStation() {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 16,
padding: 16,
alignItems: 'start',
}}
>
<PrimaryFlightDisplay mockMode size="md" showFlightDirector />
<LiveMap mockMode />
<Gauge
mockMode
mockProfile="ramp"
min={0}
max={100}
label="Battery"
unit="%"
thresholds={[
{ value: 0, color: 'var(--vt-color-danger)' },
{ value: 20, color: 'var(--vt-color-warn)' },
{ value: 40, color: 'var(--vt-color-active)' },
]}
/>
<EventLog entries={demoEvents} />
</div>
);
}
Two things in there are worth pulling out.
The gauge has a mockProfile. The default mock animation sweeps the needle up and down with a sine wave, which is fine for a generic gauge but looks absurd on a battery, because the charge appears to refill itself every few seconds. The ramp profile drains it from full to empty and resets, so a screenshot of your dashboard does not show a battery doing something physically impossible. Small detail, but it is the difference between a demo that looks considered and one that looks like a component sampler.
The event log is the odd one out. It has no mock mode, and that is on purpose. Every other component here carries a single number per channel, and the log carries text and a severity, which is a different kind of thing entirely. So instead of a mockMode flag, you hand it an array of entries and own that array yourself. Here it is a static list. Later, when we have real data, we will append to it from the flight controller's log stream. The shape is just a timestamp, a message, and one of info, warn, or error.
One layout thing that will save you a confused minute. The PFD has a fixed pixel footprint rather than a fluid one, around 680px wide at size="lg" and narrower at md. In a two-up grid that means the grid has to be wide enough to hold it, or the instrument overflows its column. I dropped to size="md" above so the dashboard sits comfortably at normal widths. If you want the larger PFD, give the grid the room for it rather than expecting the instrument to shrink.
Everything above runs without a drone, without rosbridge, and without a simulator. You can build the entire dashboard, get the grid proportions right, tune the thresholds, and hand it to a designer, all before any real telemetry exists. That is the reason to start in mock mode rather than treating it as an afterthought. The hard part of a ground station is rarely the data. It is the layout and the rendering, and you can finish both of those against fake motion.
Next we throw the mock flag away and point these same four components at a live MAVROS stack over rosbridge, which is where the interesting wiring lives: turning an IMU quaternion into something the horizon can draw, getting five channels onto one display, and feeding text logs into a component that only speaks numbers.
Wiring it to a real drone
The plan from here is the same four components, none of them changed, fed by a live MAVROS stack instead of the mock generator. The bridge between the two worlds is a small idea. Every Altara component that takes live data takes a dataSource, an object you subscribe to for a stream of timestamped numbers. rosbridge, meanwhile, hands you ROS topics over a WebSocket. So the wiring is mostly about turning topics into data sources, with a couple of places where the translation is more than a rename.
You will need a rosbridge server in front of your ROS graph (the standard rosbridge_suite), and for a MAVLink vehicle, MAVROS bridging the flight controller into ROS. Once that is up, everything below talks to it at ws://localhost:9090.
These four are the imports for the whole section:
import { useEffect, useMemo, useState } from 'react';
import { EventLog, Gauge, LiveMap, type EventLogEntry } from '@altara/core';
import { PrimaryFlightDisplay } from '@altara/aerospace';
import {
createBatteryStateAdapter,
createImuAdapter,
createRosbridgeAdapter,
mergeChannels,
} from '@altara/ros';
The PFD: five numbers, two topics, one instrument
This is the one with the actual problem in it. A primary flight display shows five live values: roll, pitch, heading, airspeed, and altitude. They do not come from one place. Roll and pitch come from the IMU, and heading, airspeed, and altitude come from the flight controller's HUD topic. The display, though, wants a single data source.
export function LivePrimaryFlightDisplay({ url }: { url: string }) {
const source = useMemo(() => {
const imu = createImuAdapter({ url, topic: '/mavros/imu/data' });
const hud = createRosbridgeAdapter({
url,
topic: '/mavros/vfr_hud',
messageType: 'mavros_msgs/VFR_HUD',
channels: {
heading: (m) => m.heading,
airspeed: (m) => m.airspeed * 1.94384, // m/s -> kt
altitude: (m) => m.altitude * 3.28084, // m -> ft
},
});
return mergeChannels({
roll: imu.roll,
pitch: imu.pitch,
heading: hud.heading,
airspeed: hud.airspeed,
altitude: hud.altitude,
});
}, [url]);
useEffect(() => () => source.destroy(), [source]);
return <PrimaryFlightDisplay dataSource={source} showFlightDirector />;
}
Three things are happening there.
createImuAdapter opens one socket to the IMU topic and gives you back roll, pitch, and yaw as separate channels. That matters more than it looks, because sensor_msgs/Imu reports orientation as a quaternion and the horizon needs degrees. The adapter does that conversion for you, and if you ever want it on its own, quaternionToEuler is exported too. There is one detail in that conversion worth knowing about, which I will come back to.
The HUD adapter uses the generic createRosbridgeAdapter with a channels map, which pulls several numbers off a single message. VFR_HUD publishes heading already in degrees, but airspeed in meters per second and altitude in meters, so the conversions to knots and feet are mine to do, right there in the extractor. The tapes display whatever number you give them. Hand them meters and they will read 4500 meters without complaint.
Then mergeChannels unions those named sources into one channel-tagged stream, keyed by the names I passed. That key is what the PFD routes on internally, so the labels are load bearing. roll has to be roll. The whole display ends up running on two sockets, one to the IMU and one to the HUD, which is about as lean as five live values across two topics gets. One more thing the merge does quietly: calling destroy() on the merged source tears down both child adapters, which is why the cleanup above only has to destroy the one thing it kept a handle to.
A note on the IMU topic, since it is an easy half hour to lose. Use /mavros/imu/data, not /mavros/imu/data_raw. The raw topic ships without orientation populated, so the quaternion is all zeros and your horizon sits there perfectly level while the aircraft rolls.
The conversion, and the value that goes NaN
The quaternion to roll/pitch/yaw conversion is standard, but the pitch term is an asin, and asin is only defined on the range minus one to one. Near vertical, where pitch approaches plus or minus 90 degrees, floating point error can push the argument a hair past one, and asin of 1.0000001 is NaN. One NaN in the pitch channel and the horizon blanks. So the conversion clamps the argument before the asin. You inherit that clamp for free through createImuAdapter, but if you pull quaternionToEuler out to use directly, that is the line that keeps a drone pointed straight up from blanking your display.
The map: GPS is two numbers that arrive separately
LiveMap is a controlled component. It does not take a dataSource, it takes a position. And GPS arrives as a sensor_msgs/NavSatFix message where latitude and longitude are two fields, which the adapter surfaces as two channels. So you merge them like the PFD, then accumulate the stream into a position object yourself.
export function LiveDroneMap({ url }: { url: string }) {
const source = useMemo(() => {
const gps = createRosbridgeAdapter({
url,
topic: '/mavros/global_position/global',
messageType: 'sensor_msgs/NavSatFix',
channels: {
lat: (m) => m.latitude,
lng: (m) => m.longitude,
},
});
return mergeChannels({ lat: gps.lat, lng: gps.lng });
}, [url]);
const [position, setPosition] = useState<{ lat: number; lng: number }>();
useEffect(() => {
const latest: { lat?: number; lng?: number } = {};
const off = source.subscribe((v) => {
if (v.channel === 'lat') latest.lat = v.value;
if (v.channel === 'lng') latest.lng = v.value;
if (latest.lat !== undefined && latest.lng !== undefined) {
setPosition({ lat: latest.lat, lng: latest.lng });
}
});
return () => {
off();
source.destroy();
};
}, [source]);
return position ? <LiveMap position={position} trackLength={800} /> : <LiveMap />;
}
The thing to notice is the accumulation. Latitude and longitude come in as separate samples, so position stays undefined until both have landed at least once. That is the position ? ... : <LiveMap /> at the end. For the first fraction of a second after connecting, before both axes have arrived, you render an empty map rather than a marker at the equator. trackLength is how many points of history the trail keeps.
The battery: when the firmware will not tell you the charge
The battery looks like the easy one, and on good hardware it is. createBatteryStateAdapter reads the state of charge straight off sensor_msgs/BatteryState and you are done.
export function LiveBatteryGauge({ url }: { url: string }) {
const source = useMemo(
() =>
createBatteryStateAdapter({
url,
topic: '/mavros/battery',
voltageRange: { min: 14.0, max: 16.8 }, // 4S LiPo (3.5–4.2 V/cell)
}),
[url],
);
useEffect(() => () => source.destroy(), [source]);
return (
<Gauge
dataSource={source}
min={0}
max={100}
label="Battery"
unit="%"
thresholds={[
{ value: 0, color: 'var(--vt-color-danger)' },
{ value: 20, color: 'var(--vt-color-warn)' },
{ value: 40, color: 'var(--vt-color-active)' },
]}
/>
);
}
The voltageRange is there for the common case where the firmware cannot give you a charge percentage at all. Plenty of PX4 and ArduPilot setups publish percentage as minus one when there is no battery estimator configured, and a gauge reading a steady minus one is useless. When that happens, the adapter falls back to estimating charge from pack voltage, with voltageRange telling it the empty and full voltages of your pack. The 14.0 to 16.8 above is a 4S LiPo, 3.5 to 4.2 volts a cell. Be honest with yourself about what that estimate is, though. A LiPo's voltage sags under load and the discharge curve is famously flat through the middle, so a voltage based percentage is a presence of charge indicator, not a fuel gauge. It will tell you the battery is there and roughly how worried to be. It will not tell you how many minutes you have left.
The event log: text in a numeric world
The other three components all carry numbers. The log carries text and a severity, which the rest of the library has no concept of. Altara's data model is numeric all the way down, deliberately, because that is what keeps the high-frequency path fast. So there is no adapter for log messages, and rather than bolt strings onto a numeric pipeline, the log just talks to rosbridge directly. You open the socket, subscribe to /rosout, and map each message into the entry shape the log wants.
const ROSOUT_LEVEL: Record<number, EventLogEntry['severity']> = {
20: 'info',
30: 'warn',
40: 'error',
50: 'error', // rcl_interfaces/Log fatal -> error
};
export function RosoutEventLog({ url }: { url: string }) {
const [entries, setEntries] = useState<EventLogEntry[]>([]);
useEffect(() => {
const ws = new WebSocket(url);
ws.onopen = () =>
ws.send(JSON.stringify({ op: 'subscribe', topic: '/rosout', type: 'rcl_interfaces/Log' }));
ws.onmessage = (e) => {
const data = typeof e.data === 'string' ? e.data : '';
if (!data) return;
const env = JSON.parse(data) as {
op: string;
topic?: string;
msg?: { level: number; name: string; msg: string; stamp?: { sec: number; nanosec: number } };
};
if (env.op !== 'publish' || env.topic !== '/rosout' || !env.msg) return;
const m = env.msg;
if (m.level < 20) return; // skip debug
setEntries((prev) =>
[
...prev,
{
timestamp: m.stamp ? m.stamp.sec * 1000 + m.stamp.nanosec / 1e6 : Date.now(),
severity: ROSOUT_LEVEL[m.level] ?? 'info',
message: `[${m.name}] ${m.msg}`,
},
].slice(-500),
);
};
return () => ws.close();
}, [url]);
return <EventLog entries={entries} maxEntries={500} />;
}
Two things in there earn their keep. The slice(-500) caps the array you hold in state, and it is yours to remember. maxEntries on the component limits what gets rendered, not what you retain, so a chatty /rosout will grow your state without bound if you forget the slice. And map the fatal level (50) explicitly. If you leave it out it falls through to the ?? 'info' default, and a crash shows up colored like a routine status line, which is the one time you really do not want that.
What it took to make the horizon not lie
The wiring above is the part you write. The part you do not see is why the PFD holds together at all, and it comes down to two decisions that were not obvious to me until the naive version failed.
The first is that the entire instrument is one canvas driven by one requestAnimationFrame loop, and incoming data never touches React state. A PFD is really six instruments sharing a frame: the attitude sphere, the airspeed tape, the altitude tape, the vertical speed needle, the heading scale, and the flight director. The obvious React build gives each of them its own state and lets props flow down. The problem shows up in a banking turn. The airspeed tape repaints on one frame, the attitude on the next, and for a moment they are showing different instants of the same maneuver. The instrument visibly disagrees with itself. The fix was to stop using React for the moving parts entirely. Data, whether from mock, props, or rosbridge, mutates a single ref, and one animation loop reads that ref every frame and paints the whole instrument at once. React mounts the canvas and then gets out of the way. There are zero re-renders during flight, and because everything is painted from one ref in one pass, the six instruments can never disagree.
The second decision is a single minus sign, and it is the kind of bug that passes every screenshot and fails in the air. The horizon moves and the aircraft symbol stays fixed, which is how real attitude indicators work. You do that with a canvas transform, not by repositioning shapes:
ctx.translate(cx, cy);
ctx.rotate((-s.roll * Math.PI) / 180); // note the minus
ctx.translate(0, s.pitch * pitchPxPerDeg);
You rotate the coordinate system and draw the sky, ground, and pitch ladder into it, which is far simpler than computing rotated coordinates for every line and clips cleanly at the edges. The minus on the roll is the part that matters. The sphere has to rotate opposite to the aircraft, so that when the drone banks right, the horizon tilts left behind a fixed wing symbol. Drop the minus and a static screenshot looks completely correct, every line where you expect it. Then the aircraft banks and the horizon banks with it, in the same direction, and the instrument is now confidently telling the pilot the opposite of the truth. The flight director bars layer on top of all this from the error between commanded and actual attitude, which is why they drift toward center as the aircraft captures the command rather than sitting pinned.
Where that leaves you
The same four panels you built against fake motion are now running on a real aircraft, and almost nothing about the components changed between the two. That is the whole argument for starting in mock mode. The dashboard, the layout, the thresholds, the thing you actually design and iterate on, none of it cares whether the numbers are real. The real work was never the rendering, which the library handles, but the translation at the edges: a quaternion into degrees, five channels onto one instrument, two GPS axes into a position, a flat discharge curve into a believable percentage, and text logs into a pipeline that otherwise only counts.
The components are MIT licensed and on npm as @altara/core, @altara/aerospace, and @altara/ros. The full source for every snippet here lives in the repo, and there is a live demo of the dashboard if you want to see it move before installing anything. The live demo goes a step past the snippet in this post, with a streaming event log and the real wiring block included, both of which are exactly what we built above.
Top comments (0)