惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

AWS News Blog
AWS News Blog
Schneier on Security
Schneier on Security
P
Palo Alto Networks Blog
T
Threat Research - Cisco Blogs
NISL@THU
NISL@THU
S
Secure Thoughts
L
LINUX DO - 最新话题
S
Securelist
C
CXSECURITY Database RSS Feed - CXSecurity.com
C
Cybersecurity and Infrastructure Security Agency CISA
Recent Announcements
Recent Announcements
S
Schneier on Security
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
T
Tailwind CSS Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Cyberwarzone
Cyberwarzone
宝玉的分享
宝玉的分享
Last Week in AI
Last Week in AI
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
酷 壳 – CoolShell
酷 壳 – CoolShell
雷峰网
雷峰网
Security Archives - TechRepublic
Security Archives - TechRepublic
V
Vulnerabilities – Threatpost
T
Tor Project blog
GbyAI
GbyAI
B
Blog
博客园 - 司徒正美
博客园 - 叶小钗
Recorded Future
Recorded Future
F
Fortinet All Blogs
Spread Privacy
Spread Privacy
IT之家
IT之家
Forbes - Security
Forbes - Security
L
Lohrmann on Cybersecurity
有赞技术团队
有赞技术团队
博客园 - 聂微东
A
Arctic Wolf
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
The Blog of Author Tim Ferriss
N
News and Events Feed by Topic
O
OpenAI News
Apple Machine Learning Research
Apple Machine Learning Research
Help Net Security
Help Net Security
Martin Fowler
Martin Fowler
WordPress大学
WordPress大学
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
TaoSecurity Blog
TaoSecurity Blog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Building a real-time drone ground control station in React
Jaya Sai Kishan Chapparam · 2026-06-19 · via DEV Community

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.