G3 is a flexible Javascript framework for building steam gauge instrument panels that display live external metrics from flight (or other) simulators like X-Plane and Microsoft FS2020. Here's a screenshot with some of the included flight gauges (or try this live demo):
- Install G3
- Get started by running a panel or designing a new gauge
- Browse the complete API reference
- Give back by contributing new gauges and panels
- Explore related resources
Keywords: G3 D3 Javascript SVG flight simulator gauges instruments control panel metrics telemetry dashboard visualization dataviz XPlane FS2020
G3's goal is to provide a lightweight, browser-based solution for building steam-gauge control panels using simple hardware atop a commodity LCD display, powered by an affordable single-board computer like the Pi. One of my original inspirations was this awesome Cessna setup. G3 could also be useful for interactive data visualizations and dashboards.
The name G3 is a tortured backronym for "generic gauge grammar"—and has an aircraft connection—but was mainly chosen in homage to D3. It's part of a quixotic pandemic project to build a DHC-2 Beaver simpit. Although there are plenty of alternatives for creating instrument panels, I decided it would be fun to roll my own based on pure Javascript with SVG using D3. After several iterations, I ended up very close to the pattern suggested by Mike Bostock several years ago in Towards reusable charts.
The iconic Omega Speedmaster watch,
mimicked by the example omegaSpeedmaster
gauge below
(see live demo),
showcases G3's flexibility to create complex, working gauges that look good:
You can skip installing altogether and just refer to a standalone copy of the module distribution from an NPM CDN like these:
https://unpkg.com/@patricksurry/g3/dist/g3-contrib.min.js
https://cdn.jsdelivr.net/npm/@patricksurry/g3/dist/g3-contrib.min.js
You can also set up an npm project, by creating a project directory and installing G3:
npm install @patricksurry/g3
If you prefer, you can download a
bundled distribution from github,
by picking any of g3[-contrib][.min].js
,
clicking the 'Raw' button, and then right-clicking to "save as".
The base g3
package provides the API to define gauges and assemble them into control panels,
and the g3-contrib
package adds a bunch of predefined gauges and panels
that you can use or modify. The .min
versions are minified to load slightly faster,
but harder to debug. Choosing g3-contrib.js
is probably a good start.
See Contributing if you want to hack on G3.
Check things are working by creating a minimal HTML file to show a gallery
of all existing gauges, updated with fake metrics.
Create a new file called gallery.html
that looks like this
(or use tutorial/gallery.html
for a slightly prettier version that includes the pointer gallery):
<html>
<body>
<script src="https://unpkg.com/@patricksurry/g3/dist/g3-contrib.min.js"></script>
<script>
g3.gallery.contrib('body');
</script>
</body>
</html>
This tells G3 to fetch the g3.gallery.contrib
panel definition,
and draw it as an SVG object appended to the HTML <body>
element.
By default the panel provides fake metrics so you'll see gauges moving
in a somewhat realistic but random way.
Save the file and use a terminal window to serve it locally using your favorite HTTP server.
For example try:
python -m http.server
or
npx http-server -p 8000
and then point your browser at http://localhost:8000/test.html. You should see something that looks a bit like the live demo above.
The contrib folder
included with g3-contrib.js
is where all the predefined gauges live, including
clocks, flight instruments, as well as engine and electrical gauges.
The full index of definitions can be found __index__.js
file,
and two predefined panels, g3.gallery.contrib
and g3.gallery.pointers
,
will draw a gallery of all known gauges and indicator pointer types respectively as we saw above.
Let's create our own panel that shows a clock and a heading gauge side by side.
Create a new HTML file called panel.html
:
<html>
<body>
<script src="https://unpkg.com/@patricksurry/g3/dist/g3-contrib.min.js"></script>
<script>
var panel = g3.panel()
.width(600).height(300)
.append(
g3.put().x(150).y(150).append(g3.contrib.clocks.simple()),
g3.put().x(450).y(150).append(g3.contrib.nav.heading.generic()),
);
panel('body');
</script>
</body>
</html>
This defines a layout we've called panel
which specifies
a 600x300 SVG container, and places two existing gauges
at (150,150) and (450,150) respectively,
with names referencing the definitions in src/contrib/__index__.js
.
By convention gauges are drawn with a radius of 100 SVG units,
but you can scale via put().scale(1.5)...
if (say) you prefer a radius 150.
Note that the panel specification in the panel
variable is actually a
function that will draw the panel when we call it with a CSS selector
like panel('body')
.
In this case the panel is appended directly to the HTML <body>
element,
and will start polling for metric updates as soon as it's drawn.
Serve locally as before and browse to http://localhost:8000/panel.html and you should see something like this:
We've built a panel, but by default it's displaying fake metrics that are generated in the browser.
G3 normally polls an external URL for metrics,
and expects a response containing
a JSON dictionary
with an entry for each metric.
Unless we specify a polling interval,
it will check four times per second (an interval of 250ms).
Let's modify panel.html
, replacing panel('body');
with:
...
panel.interval(500).url('/metrics/fake.json')('body');
...
Now we'll need a server that provides metrics at the /metrics/fake.json
endpoint.
To start with we'll run a server that also provides fake metrics,
so the behavior will look similar but gives us the stubs to hook it up to whatever source we want.
Grab this sample G3 python server
based on FastAPI from github.
Copy your panel.html
to the g3py/panels/
folder,
and follow the README to launch the server and browse to your control panel:
http://localhost:8000/panels/panel.html
You should see your panel working again, but the browser console log should show that it's fetching metrics from the server. More usefully, our server would fetch metrics from our simulation, for example via SimConnect for FS2020, or XPlaneConnect for X-Plane, and deliver those to our endpoint.
XPlane 11
Let's hook up our flight panel to
XPlane 11.
Once you've got XPlane installed (the demo version works fine),
install NASA's XPlaneConnect plugin.
Assuming you've already grabbed the
G3 python server
you can just modify panel.html
to point at the demo XPlane endpoint:
g3.panel('DHC2FlightPanel').interval(250).url('/metrics/xplane.json')('body');
Now start up a flight in XPlane and open your panel in the browser as above. You should see our flight panel mimicking the live XPlane display. You can see how the server maps XPlane metrics via datarefs in mapping.yml here. It will even automatically convert between compatible units to match your gauges!
Microsoft Flight Simulator (FS2020)
TODO: include an example based on FS2020 SimConnnect
The easiest way to get started with gauge design is to find a
similar gauge in src/contrib
and experiment with its
implementation.
As a simple example of building a gauge from scratch, let's create a
classic Jaguar E-type tachometer (photo, below left).
With a few lines of code, we'll end up with a credible facsimile (below right).
Let's get started by creating a new HTML file called jagetach.html
,
and build a skeleton for our gauge.
We'll choose a name for the gauge, specify the external metric it should display
(with explicit units if possible), then define a measure which
translates the metric values to the scale on our gauge.
The trickiest part is to estimate the angular range of the tachometer scale.
We can guesstimate by eye (it looks like it occupies about 2/3 of the circle,
so a range of -120° to +120°), we could measure with a protractor,
or we can draw a line through the center point of the original image
and see what range of metric values span 180°.
In our example, 180° represents a range of about 43(00) RPM so we want a
total span of 60/43*180 or about 250° in total, ranging from -125° to +125°.
After defining the measure, we'll add a default face (dark-shaded circle)
along with an axis line, ticks and labels, and then draw it to the screen.
Here's what we have so far (see result below left):
<html>
<body>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://unpkg.com/@patricksurry/g3/dist/g3-contrib.min.js"></script>
<script>
var g = g3.gauge()
.metric('engineRPM').unit('rpm')
.measure(d3.scaleLinear().domain([0,6000]).range([-125,125]))
.append(
g3.gaugeFace(),
g3.axisLine(),
g3.axisTicks(),
g3.axisLabels(),
);
var p = g3.panel()
.width(640)
.height(640)
.append(
g3.put().x(320).y(320).append(g)
);
p('body');
</script>
</body>
</html>
Now let's scale the gauge to make it little larger in the panel, and then inset and customize the axis marks to look a little more like the original. We'll also add a default pointer so we can actually see our RPM measurement (result above right):
...
.append(
g3.gaugeFace(),
g3.put().scale(0.95).append(
g3.axisSector().style('fill: none; stroke: white'),
g3.axisTicks().step(500).style('stroke-width: 6'),
g3.axisTicks().step(100).size(5),
g3.axisLabels().inset(18).size(15).format(v => v/100),
g3.indicatePointer(),
),
...
g3.put().x(320).y(320).scale(2).append(g)
...
Finally, we'll add a few labels and customize the pointer to look closer to the source, though the fonts and logo could certainly use more love (result top right of this section). The custom pointer is probably the fiddliest part, but is not too bad with a basic understanding of SVG paths. It's also possible to extract path definitions using a text editor, either from existing SVG images or files created via an SVG drawing tool like Inkscape.
var g = g3.gauge()
.metric('engineRPM').unit('rpm')
.measure(d3.scaleLinear().domain([0,6000]).range([-125,125]))
.css(`
text.g3-gauge-label, .g3-axis-labels text {
font-stretch: normal;
font-weight: 600;
fill: #ccc;
}
.g3-gauge-face { fill: #282828 }
`)
.append(
g3.gaugeFace(),
g3.gaugeFace().r(50).style('filter: url(#dropShadow2)'),
g3.axisSector([5000,6000]).inset(50).size(35).style('fill: #800'),
g3.gaugeLabel('SMITHS').y(-45).size(7),
g3.gaugeLabel('8 CYL').y(40).size(7),
// a trick to put a circular path label opposite the 3000RPM top of the gauge
g3.put().rotate(180).append(
g3.axisLabels({3000: 'POSITIVE EARTH'}).orient('counterclockwise').size(3.5).inset(52)
),
g3.gaugeLabel('RPM').y(65).size(12),
g3.gaugeLabel('X 100').y(75).size(8),
g3.gaugeScrew().shape('phillips').r(3).x(-20),
g3.gaugeScrew().shape('phillips').r(3).x(20),
g3.put().scale(0.95).append(
g3.axisSector().style('fill: none; stroke: white'),
g3.axisTicks().step(500).style('stroke-width: 5'),
g3.axisTicks().step(100).size(5),
g3.axisLabels().inset(20).size(15).format(v => v/100),
g3.indicatePointer().append(
// the full pointer blade
g3.element('path', {d: 'M 3,0 l -1.5,-90 l -1.5,-5 l -1.5,5 l -1.5,90 z'})
.style('fill: #ddd'),
// the bottom half of the pointer, drawn over the full blade
g3.element('path', {d: 'M 3,0 l -0.75,-45 l -4.5,0 l -0.75,45 z'})
.style('fill: #333'),
// a blurred highlight on the blade to give a bit of 3D effect
g3.element('path', {d: 'M -1,0 l 0,-90 l 2,0 z'})
.style('fill: white; filter: url(#gaussianBlur1); opacity: 0.5'),
// the central hub, with a highlight
g3.element('circle', {r: 15}).style('fill: #ccd'),
g3.element('circle', {r: 15}).class('g3-highlight'),
// the central pin
g3.element('circle', {r: 5}).style('fill: #333'),
),
),
);
If you're obsessed with matching your original gauge "perfectly", a helpful trick is to temporarily overlay a high quality, partially transparent image of the original (or half the original) on top of your gauge. For example, modify your panel to look like:
var p = g3.panel().width(640).height(640).append(
g3.put().x(320).y(320).scale(2).append(
g,
g3.element('image', {href: 'original.png', x: -100, y: -100, width: 200, opacity: 0.3})
)
);
In this tutorial we reused the engineRPM
metric defined with the
sample engine gauges,
but it's easy to add our own.
Simply choose a name for the metric that matches how it will be provided from the external source,
and (if desired) define a corresponding fake metric for testing.
Note it's often a good idea to test metric values outside the expected range
(e.g. max of 7000 instead of the max label of 6000)
to ensure your gauge behaves as expected. For our tachometer we probably
want to clamp the pointer as if there was a physical stop at 6000:
var g = g3.gauge()
.metric('jaguarRPM').unit('rpm')
.fake(g3.forceSeries(0, 7000))
.clamp([0, 6000])
...
Extra credit If you want to explore further, try modifying the tachometer to add a simple sub-gauge in the bottom quadrant which shows a simple clock with hours and minutes.
If you want to extend G3, improve the documentation,
or just add your own examples back into the package,
you should start by forking and cloning the
git repo
followed by npm install
.
I'm currently tracking a few open issues and ideas in TODO.md
if you're looking for something to play with.
You can test your changes locally by serving directly from the src/
tree using
Web Dev Server.
Start it via npm run start
and you should see the debug panel in your browser,
served from src/index.html
.
(Note this HTML script uses module style imports unlike the production distribution.)
You can rebuild the bundled package in dist/
using rollup
by typing npm run build
.
Once you're happy with your changes, make a pull request.
When I want to publish a new release, I also bump the version in package.json
,
then commit and tag in github and finally publish with something like:
git tag v0.1.18
git push origin --tags
npm login
npm publish --access public
- Towards reusable charts
- Python SimConnect Python interface for MS FS2020 using SimConnect
- Sim Innovations create instrument panels with Air Manager
- Home cockpit simulator control interface combine physical instruments and panels
- MobiFlight open source project integrating hardware with your flight sim
See doc/API.md