Skip to content

patricksurry/g3

Repository files navigation

G3: a flexible framework for steam gauge instrument panels

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):

flight panel screenshot

TL;DR

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:

speedmaster screenshot

Installing

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.

Getting started

Browse existing gauges

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.

Define your own panel with existing gauges

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:

simple panel screenshot

Display real metrics

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

Create a new gauge

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).

Actual Jaguar E-type tachometer Simulated Jaguar E-type tachometer

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>
Jaguar tachometer skeleton Jaguar tachometer custom

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.

Contributing

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

Resources

API

See doc/API.md