Instrument packages for PanelNinja consist of four files/types of files:
- config.json: The main configuration file defining the instrument
- A thumbnail SVG or PNG file: Used to display a thumbnail of the instrument on the Manage Instruments screen in PanelNinja
- One or more functional SVG or PNG files: Used to display various image layers of the instrument itself
- One or Javascript files: Used to define how to transform the instrument image layers in response to data returned from Infinite Flight
We will learn how to construct an instrument by walking through the construction of the default Heading instrument included with PanelNinja. You can download a copy for yourself using the Save button for that instrument on the Manage Instruments screen:
All instrument packages are distributed as ZIP files. In the case of the default Heading file, the included files are:
- config.json: defines the configuration of the instrument
- heading.svg: provides the thumbnail for the panel used in the Manage Instruments screen
- heading_background.svg: provides the background layer of the instrument itself
- heading_dial.svg: provides the dial layer of the instrument which rotates to reflect the heading
- heading_foreground.svg: provides the foreground layer of the instrument itself
- formula.js: provides the logic to apply to rotate the dial whenever a new heading is received from Infinite Flight
We will use this instrument as an example throughout this article.
Basic Principles
Instruments are built out of three core principles:
- The presentation of the instrument will be built from one or more image layers -- typically PNG files with transparent sections to show parts of the layers below them
- A list of data points from Infinite Flight which are needed to animate/render the instrument
- Javascript formulas that are used to process these data points and specify visual transformations to the image layers
You will need some basic knowledge of CSS and JavaScript to be able to build instruments yourself.
Creating Your Instrument Visuals
Typically instrument visuals need to be composed of multiple image layers in order to allow you to independently manipulate specific parts of an instrument as you receive data from Infinite Flight.
In our example Heading panel, we have three image layers:
heading_background.svg: The background of the instrument
heading_dial.svg: The main dial of the instrument which will rotate to reflect the heading
heading_foreground.svg: An image which sits in a fixed position on top of the
dial providing the pointer for where to read the heading from the dial
When stacked on top of each other the result looks like this:
Composite instrument visual by stacking the three image layers
In the case of the Heading instrument, only the dial layer will ever move by being rotated to represent the appropriate heading:
The dial will rotate in response to data from Infinite Flight
Selecting Infinite Flight Data Points
Infinite Flight provides the Infinite Flight Connect API which allows applications like PanelNinja to obtain data about various aspects of flight status from the Infinite Flight app.
This API provides a manifest of available commands for getting data points from Infinite Flight. This manifest varies depending on the type of aircraft being used for a flight -- but the names of the data points do not change.
To build and configure your instrument you will need the precise name of the data point you need from Infinite Flight to properly display different layers of your instrument visual. You can find a list of the data point names from a Cessna 172 aircraft in our documentation.
For the example Heading instrument, we are using the aircraft/0/heading_magnetic data point to rotate the dial layer of our instrument visual.
Creating A Transformation Formula
In creating an instrument you can specify a transformation formula for any pair of data point and image layer in your instrument. For instance, in the sample Heading instrument we only have a single transformation formula formula for responding to the aircraft/0/heading_magnetic data point to apply a rotation to the dial layer of our instrument.
In order to properly build a transformation formula you need two key skills:
- Sufficient CSS knowledge to define your visual transformations via CSS
- Sufficient JavaScript to write the formula logic itself
Often the skills/knowledge needed are minimal as will be illustrated in this Heading example.
Each transformation formula consists of a single JavaScript file which exports a function called fn:
exports.fn = (arguments) => { // Do something // Return transformation object };
PanelNinja passes five arguments to this function when it receives an update to the associated data point:
datapoint
This contains the actual value of the data point returned by the Infinite Flight API. This is the only required argument and often all that's needed to build a transformation formula -- as in the case of the Heading example.
activedata
This contains an object containing all data points which have been fetched by PanelNinja. All instruments register the data points they require and all transformation formulas have access to an object containing the complete list of these data points and the most recent value received from Infinite Flight.
The following is an example of what this object looks like (represented as a JSON string):
{ "aircraft/0/heading_magnetic": 4.956211566925049, "aircraft/0/pitch": 0.005095240194350481, "aircraft/0/bank": 0.000445551733719185, "aircraft/0/vertical_speed": 0.000013569077054853551, "aircraft/0/2_minutes_turn_ratio": 3.4427475981146927e-8 }
In some cases you might find that you need two data points in a single transformation formula. Check out the Horizon instrument provided with PanelNinja to see an example of this in the background_pitch.js transformation formula.
transform
The current value of the transform CSS attribute applied to the targeted image layer HTML element. In many cases, the transform attribute is the only attribute used to manipulate the image layer and a transformation formula may need to know the current state before defining the new state.
The transform CSS attribute is represented in a very specific format such as:
matrix(0.999907, 0.013671, -0.013671, 0.999907, 0, 0)
A discussion of this matrix and what it means is out-of-scope of this article. If you think you need the data and want to learn more about this value, a good explanation can be found here: CSS Matrix - a mathematical explanation (intmath.com).
width
The width of the target image layer DOM element in pixels.
height
The height of the target image layer DOM element in pixels.
If you wanted to use all of these data points, you might define a function like this:
exports.fn = (datapoint, activedata, transform, width, height) => { // Do something // Return transformation object };
For our Heading example we only need the data point itself:
exports.fn = (datapoint) => { // Do something // Return transformation object };
The formula function itself needs to do two things:
- Perform some logic to calculate how to transform the image layer
- Return an transformation object which tells PanelNinja how to transform the presentation of the image layer
In the Heading example, the logic we need to perform is simple:
- Take the data returned by Infinite Flight and transform from radians to degrees because the API returns the heading in Radians.
- Calculate how to rotate the dial based on this -- because we are rotating the numeric dial and not the pointer, we need to rotate the opposite direction (i.e. if the heading is 10 degrees we need to rotate to 350 degrees).
- Decide if we will animate the rotation -- typically we will animate any changes in the dial rotation over a tenth of a second to create a smooth visual effect; the only time we don't do this is when we are rotating over the 0/360 degree mark as animating that transition causes a rapid full rotation of the dial to be visible to the user and is very jarring visually (i.e. if we are moving from a heading of 359.9 to a heading of 0.1, the transition would be a full rotation from 359.9 down to 0.1 and if that is animated the user will see the rotation). We apply this transition through the CSS transition attribute.
Let's add these three steps to the transformation formula:
exports.fn = (datapoint) => { // Get the heading in radians from IF and convert to degrees let headingDeg = (datapoint * 180) / Math.PI; // Inverse it because of how our dial graphics work (so a heading of 1 degree requires rotating the dial to 359 degrees) let finalHeading = 360 - headingDeg // Define our default transition which is a .1 second animation var transition = "transform .1s linear"; // Remove the transition when we are close to North or the visual behaviour is weird when we cross north if (finalHeading >= 359 || finalHeading <= 1) { transition = "none"; } };
The final step we need to add is to return a transformation object. This is a simple JavaScript option where the property names are the names of CSS style attributes and the values are the values to apply to those CSS attributes. This allows you to define any number of CSS changes in a transformation object.
In the case of the Heading instrument example we are only changing two CSS attributes:
- transition: to reflect whether to animate the transformation as discussed above
- transform: to indicate the rotation of the dial layer in degrees
The final transformation function looks like this:
exports.fn = (datapoint) => { // Get the heading in radians from IF and convert to degrees let headingDeg = (datapoint * 180) / Math.PI; // Inverse it because of how our dial graphics work (so a heading of 1 degree requires rotating the dial to 359 degrees) let finalHeading = 360 - headingDeg // Define our default transition which is a .1 second animation var transition = "transform .1s linear"; // Remove the transition when we are close to North or the visual behaviour is weird when we cross north if (finalHeading >= 359 || finalHeading <= 1) { transition = "none"; } // Return the transition and transform for the dial return { transition: transition, transform: 'rotate('+finalHeading+'deg)', }; };
Define the Instrument Configuration
The next step in building a custom instrument is to define its configuration in a config.json file.
The configuration file from the Heading instrument looks like this:
{ "name": "Heading", "author": "FlightSim Ninja", "url": "https://flightsim.ninja", "thumbnail": "heading.svg", "credit": "Created by <span class='browser' pnurl='https://flightsim.ninja/'>FlightSim Ninja</span> using images from distributed under the <span class='browser' pnurl='https://creativecommons.org/licenses/by-sa/4.0/deed.en'>Creative Commons Attribution-Share Alike 4.0 International (CC BY-SA 4.0) license</span> by <span class='browser' pnurl='https://commons.wikimedia.org/wiki/File:BASIC_Flight_instruments_Improved.svg'>רונאלדיניו המלך on WikiMedia Commons</span>. The images of this instrument can be redistributed under the same license.", "description": "Heading GA Panel (The push button is decorative only and does not function)", "license": "This instrument is distributed with PanelNinja but may be reused freely -- including editing it to create your own instruments which you can then distribute", "data": { "aircraft/0/heading_magnetic": { "targets": [ { "target": "dial", "transform": "formula.js" } ] } }, "layers": { "background": { "image": "heading_background.svg", "css": { "position": "absolute", "width": "100%", "height": "100%", "transition": "transform .1s linear" }, "elements": { "img": { "css": { "position": "absolute", "top": "50%", "left": "50%", "margin-top": "-45.4545%", "margin-left": "-45.4545%", "width": "90.90909%", "height": "90.90909%" } } } }, "dial": { "image": "heading_dial.svg", "css": { "position": "absolute", "width": "100%", "height": "100%" }, "elements": { "img": { "css": { "position": "absolute", "top": "50%", "left": "50%", "margin-top": "-45.4545%", "margin-left": "-45.4545%", "width": "90.90909%", "height": "90.90909%" } } } }, "foreground": { "image": "heading_foreground.svg", "css": { "position": "absolute", "width": "100%", "height": "100%", "transition": "transform .1s linear" }, "elements": { "img": { "css": { "position": "absolute", "top": "50%", "left": "50%", "margin-top": "-45.4545%", "margin-left": "-45.4545%", "width": "90.90909%", "height": "90.90909%" } } } } } }
Logically, you can view this configuration as containing three "sections":
- A set of meta data such as the instrument name, author, URL, credits and so on
- A data section defining the data points which the instrument wants to use and the transformation functions to apply when the data point changes
- A layers section which defines the visual presentation of the various layers of the instrument
Meta Data Section
The following meta data properties can be included in your instrument definition:
Property | Description |
name | The initial display name of the instrument which will be presented after the instrument is imported into PanelNinja: |
author | The name of the author which will be presented after the instrument is imported: |
url | The full URL (including protocol) for the instrument or author's web site: |
thumbnail | The file name of a thumbnail image included in the instrument package to be displayed in PanelNinja: |
credit | Any credits or acknowledgements the instrument author needs to provide (for instance if reusing third-party open/free content or code): Note: If you need to include links to external web sites in this text, refer to the section below "Creating External Links in Meta Data". |
description | The description of the instrument to display in PanelNinja: Note: If you need to include links to external web sites in this text, refer to the section below "Creating External Links in Meta Data". |
license | Any license information you want/need to include in the instrument. This will not be displayed in PanelNinja but is available for anyone referring to the contents of your image package. |
Creating External Links in Meta Data
The credits and description fields can support standard HTML such as <em> and <strong> elements. However, you cannot use the standard <a href="some url"> element to create links to external web sites.
Instead, you can use the following HTML in these fields to create links to external web sites:
<span class='browser' pnurl='link url'>link text</span>
For instance, in the examples above credits field contains a link to the FlightSim Ninja web site:
<span class='browser' pnurl='https://flightsim.ninja/'>FlightSim Ninja</span>
data Section
The data section is used to define the transformation formulas for data point/instrument layer pairs.
it uses a two tier structure consisting of a set of properties indicating the data points used by the instrument. Each property in turn contains a targets property which is an array of object specifying the a layer to transform and the transformation function to use.
This result looks like this:
"data": { "data point name 1": { "targets": [ { "target": "target layer name 1", "transform": "formula javascript file 1" } ] } , "data point name 2": { "targets": [ { "target": "target layer name 2a", "transform": "formula javascript file 2a" }, { "target": "target layer name 2b", "transform": "formula javascript file 2b" } ] } , etc }
In the Heading example we define a single data point with a single target:
"data": { "aircraft/0/heading_magnetic": { "targets": [ { "target": "dial", "transform": "formula.js" } ] } }
This indicates that for the aircraft/0/heading_magnetic data point we want to transform the layer named dial (see the section below "layers Section" to understand how layers are named) using the transformation function in the file formula.js.
layers Section
The layers section defines the visual presentation of an instrument and its layers. It consists of a series of objects defining the stack of layers from bottom to top. For instance, if you have three layers, the section would look like this:
"layers": { "bottom layer name": { bottom layer definition here }, "middle layer name": { middle layer definition here }, "top layer name": { top layer definition here } }
In turn, each layer contains three properties:
Property | Description |
image | Specifies the file name of the image to display for this layer |
css | Specifies a set of CSS attributes and values to apply to the layer's container |
elements | Specifies a set of CSS attributes to apply to DOM elements within the layer container -- at this time only an img element can be used |
Taking the example of the background layer from the Heading instrument, the following illustrates these properties:
"layers": { "background": { "image": "heading_background.png", "css": { "position": "absolute", "width": "100%", "height": "100%", "transition": "transform .1s linear" }, "elements": { "img": { "css": { "position": "absolute", "top": "50%", "left": "50%", "margin-top": "-45.4545%", "margin-left": "-45.4545%", "width": "90.90909%", "height": "90.90909%" } } } }, ... other layers go here ... }
To fully understand how to use these CSS attributes, it is necessary to understand how PanelNinja will render your instrument and its layers. The following is the HTML that is rendered for the background layer of the Heading instrument:
<div class="background" style="position: absolute; width: 100%; height: 100%; transition: transform 0.1s linear 0s;"> <img src="instruments/fsn-heading/heading_background.png" style="position: absolute; top: 50%; left: 50%; margin-top: -45.4545%; margin-left: -45.4545%; width: 90.9091%; height: 90.9091%;"> </div>
There are several key points to note here:
- The CSS attributes in the css property are applied to the outer div container element
- The CSS attributes in the img property of the elements property are applied to the img element inside the container
- It is usually best to apply these default CSS attributes to the outer container -- anything else may behave oddly but you are free to experiment:
- position: absolute
- width: 100%
- height: 100%
- The width and height of the outer container are managed by PanelNinja and default to 440px and are then adjusted based on window size changes
- Your images for the layers can be any size but you need to apply percentages that are appropriate to display them within the 440px x 440px outer container -- in PanelNinja's default instruments we typically use 400px x 400px images and then place them accordingly
- You will see that the top, left, margin-top, margin-left, width and height attributes of the img element's CSS are all percentages -- so that they remain relative to the size of the outer container as PanelNinja adjusts that size
Packaging an Instrument for Distribution
Instruments needs to be packaged into a ZIP file containing all their files for distribution as stand-alone instrument packages. The Import Instrument function of the Manage Instruments screen in PanelNinja expects the instrument file to be a ZIP file.
Alternately, you can include a custom instrument in a custom panel package in which case you don't need to package the instrument into a ZIP file but instead the instrument files are included in the custom panel's ZIP package file. See Creating Custom Panels for more information.
In general, the best approach is to keep your file and folder structure flat -- places all the files you create (configuration, JavaScript files and images) in a single folder with no child folders. Then all your file references discussed above are simple relative references to that folder where the configuration file is located.
You can then ZIP the files up into a simple ZIP file with no folder hierarchy. In the case of the Heading file this produces a ZIP file with contents that look like this:
Archive: fsn-heading.zip Length Date Time Name --------- ---------- ----- ---- 3346 08-27-2021 21:36 config.json 979 08-27-2021 21:36 formula.js 55162 08-27-2021 21:36 heading.svg 59022 08-27-2021 21:36 heading_background.svg 32999 08-27-2021 21:36 heading_dial.svg 11096 08-27-2021 21:36 heading_foreground.svg
You can then import the instrument into PanelNinja and use it in any custom panel layouts you create.