signalk-instrument-widgets 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # signalk-instrument-widgets
2
+
3
+ Add live instrument widgets to your chartplotter. Works with any chartplotter
4
+ that supports Signal K plotter extensions (such as Freeboard-SK).
5
+
6
+ - **Gauge** — a dial for any numeric value.
7
+ - **Meter** — a 0–100% bar for ratio-style values.
8
+ - **Switch** — shows an on/off value; tap to toggle it.
9
+ - **Display Value** — a clean text readout: a small label on top, the live
10
+ value large in the middle, and an abbreviation below — e.g. "Speed over
11
+ ground" / value / "SOG".
12
+
13
+ ## Using the widgets
14
+
15
+ In your chartplotter, press and hold an **empty** widget area to add a
16
+ widget — its setup panel opens automatically. Press and hold a **placed**
17
+ widget to reconfigure or remove it.
18
+
19
+ Each widget is configured on its own: pick the value to show, choose the
20
+ units, and set the range and labels. So you can place two gauges showing two
21
+ different values side by side. Your settings are remembered for each widget.
22
+
23
+ ## Demo switch
24
+
25
+ If your server has no real switches to control (for example a demo or
26
+ playback setup), the plugin can provide a demo switch so you can try the
27
+ Switch widget anywhere. It is on by default and can be turned off in the
28
+ plugin settings.
29
+
30
+ ## License
31
+
32
+ MIT
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "signalk-instrument-widgets",
3
+ "version": "0.5.0",
4
+ "description": "Gauge, meter and switch widgets for Signal K chartplotters that support the plotterExtensions resource type",
5
+ "main": "plugin/index.js",
6
+ "keywords": [
7
+ "signalk-node-server-plugin",
8
+ "signalk-category-chart-plotters"
9
+ ],
10
+ "files": [
11
+ "plugin",
12
+ "public",
13
+ "docs/screenshots",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "build": "node scripts/build.mjs",
19
+ "test": "node --test \"test/**/*.test.js\" \"test/**/*.test.mjs\"",
20
+ "prepare": "node scripts/build.mjs"
21
+ },
22
+ "author": "Joel Kozikowski",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/joelkoz/signalk-instrument-widgets.git"
27
+ },
28
+ "devDependencies": {
29
+ "esbuild": "^0.27.0",
30
+ "signalk-plotterext-bus": "file:../signalk-plotterext-bus"
31
+ },
32
+ "signalk-plugin-enabled-by-default": false,
33
+ "signalk": {
34
+ "displayName": "Instrument Widgets",
35
+ "appSupport": "none",
36
+ "screenshots": [
37
+ "./docs/screenshots/1_freeboard-widget.png"
38
+ ],
39
+ "appstore": {
40
+ "displayName": "Instrument Widgets",
41
+ "categories": [
42
+ "chart-plotters"
43
+ ],
44
+ "author": "Joel Kozikowski",
45
+ "description": "Gauge, meter and switch widgets for Signal K chartplotters that support the plotterExtensions resource type"
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,201 @@
1
+ // signalk-instrument-widgets
2
+ //
3
+ // Reference extension for the plotterExtensions specification. The plugin
4
+ // itself is intentionally small: it registers a read-only plotterExtensions
5
+ // resource provider whose single resource is this extension's manifest, and
6
+ // (optionally) provides a demo switch path with a PUT handler so the switch
7
+ // widget can be exercised against playback data that has no real switches.
8
+ //
9
+ // The widget/panel web assets live in public/ and are served by the plugin
10
+ // itself, mounted as a top-level Express static route at
11
+ // /plotterext/<package-name>/. This is a public route (no token required,
12
+ // same as the old signalk-webapp mechanism) but, unlike a signalk-webapp, it
13
+ // does NOT appear in the server's Webapps launcher — these assets are only
14
+ // ever loaded inside a host chartplotter's iframe, never launched directly.
15
+ // It is deliberately NOT a /plugins/* route: those are admin-gated, which
16
+ // would break read-only users.
17
+
18
+ const path = require('path')
19
+
20
+ const PLUGIN_ID = 'signalk-instrument-widgets'
21
+ const ASSET_BASE = `/plotterext/${PLUGIN_ID}`
22
+ const PUBLIC_DIR = path.join(__dirname, '..', 'public')
23
+ const DEMO_SWITCH_PATH = 'electrical.switches.demo.state'
24
+
25
+ const pkg = require('../package.json')
26
+
27
+ function buildManifest() {
28
+ return {
29
+ name: 'Instrument Widgets',
30
+ description:
31
+ 'Single-value instrument widgets: gauge, percent meter and switch.',
32
+ version: pkg.version,
33
+ apiVersion: '1',
34
+ requires: ['widgets', 'panels.iframe', 'signalk.stream'],
35
+ optional: ['signalk.put'],
36
+ widgets: [
37
+ {
38
+ id: 'gauge',
39
+ title: 'Gauge',
40
+ type: 'iframe',
41
+ url: `${ASSET_BASE}/gauge.html`,
42
+ size: '1x1',
43
+ configPanel: 'instrument-config',
44
+ lifecycle: 'whileEnabled'
45
+ },
46
+ {
47
+ id: 'meter',
48
+ title: 'Meter (0-100%)',
49
+ type: 'iframe',
50
+ url: `${ASSET_BASE}/meter.html`,
51
+ size: '2x1',
52
+ configPanel: 'instrument-config',
53
+ lifecycle: 'whileEnabled'
54
+ },
55
+ {
56
+ id: 'switch',
57
+ title: 'Switch',
58
+ type: 'iframe',
59
+ url: `${ASSET_BASE}/switch.html`,
60
+ size: '1x1',
61
+ configPanel: 'instrument-config',
62
+ lifecycle: 'whileEnabled'
63
+ },
64
+ {
65
+ id: 'display',
66
+ title: 'Display Value',
67
+ type: 'iframe',
68
+ url: `${ASSET_BASE}/display.html`,
69
+ size: '1x1',
70
+ configPanel: 'instrument-config',
71
+ lifecycle: 'whileEnabled'
72
+ }
73
+ ],
74
+ panels: [
75
+ {
76
+ id: 'instrument-config',
77
+ title: 'Instrument Setup',
78
+ type: 'iframe',
79
+ url: `${ASSET_BASE}/config.html`,
80
+ lifecycle: 'onOpen'
81
+ }
82
+ ]
83
+ }
84
+ }
85
+
86
+ module.exports = (app) => {
87
+ let providerRegistered = false
88
+ let assetsMounted = false
89
+ let demoSwitchState = 0
90
+ let running = false
91
+
92
+ const debug = (msg) => app.debug(`${PLUGIN_ID}: ${msg}`)
93
+
94
+ // Serve public/ as a top-level static route. Express is provided by the
95
+ // Signal K server, so requiring it adds no runtime dependency of our own.
96
+ // Guarded so the test harness (a fake app with no .use) is a no-op.
97
+ const mountAssets = () => {
98
+ if (assetsMounted) return
99
+ if (typeof app.use !== 'function') return
100
+ let serveStatic
101
+ try {
102
+ serveStatic = require('express').static
103
+ } catch {
104
+ app.error(`${PLUGIN_ID}: express unavailable; cannot serve ${ASSET_BASE}`)
105
+ return
106
+ }
107
+ app.use(ASSET_BASE, serveStatic(PUBLIC_DIR))
108
+ assetsMounted = true
109
+ debug(`assets served at ${ASSET_BASE}`)
110
+ }
111
+
112
+ const registerProvider = () => {
113
+ if (providerRegistered) return
114
+ if (typeof app.registerResourceProvider !== 'function') {
115
+ app.error(`${PLUGIN_ID}: server has no resource provider registry`)
116
+ return
117
+ }
118
+ app.registerResourceProvider({
119
+ type: 'plotterExtensions',
120
+ methods: {
121
+ listResources: async () => {
122
+ if (!running) return {}
123
+ return { [PLUGIN_ID]: buildManifest() }
124
+ },
125
+ getResource: async (id) => {
126
+ if (!running || id !== PLUGIN_ID) {
127
+ throw new Error(`No such plotterExtensions resource: ${id}`)
128
+ }
129
+ return buildManifest()
130
+ },
131
+ setResource: async () => {
132
+ throw new Error(`${PLUGIN_ID} is a read-only provider`)
133
+ },
134
+ deleteResource: async () => {
135
+ throw new Error(`${PLUGIN_ID} is a read-only provider`)
136
+ }
137
+ }
138
+ })
139
+ providerRegistered = true
140
+ }
141
+
142
+ const emitDemoSwitch = () => {
143
+ app.handleMessage(PLUGIN_ID, {
144
+ updates: [
145
+ {
146
+ values: [{ path: DEMO_SWITCH_PATH, value: demoSwitchState }]
147
+ }
148
+ ]
149
+ })
150
+ }
151
+
152
+ const startDemoSwitch = () => {
153
+ app.registerPutHandler(
154
+ 'vessels.self',
155
+ DEMO_SWITCH_PATH,
156
+ (_context, _path, value) => {
157
+ demoSwitchState = value === true || value === 1 || value === '1' ? 1 : 0
158
+ emitDemoSwitch()
159
+ return { state: 'COMPLETED', statusCode: 200 }
160
+ }
161
+ )
162
+ emitDemoSwitch()
163
+ debug(`demo switch active at ${DEMO_SWITCH_PATH}`)
164
+ }
165
+
166
+ return {
167
+ id: PLUGIN_ID,
168
+ name: 'Instrument Widgets',
169
+ description:
170
+ 'Gauge, meter and switch widgets for chartplotters that support the plotterExtensions resource type.',
171
+
172
+ schema: () => ({
173
+ type: 'object',
174
+ properties: {
175
+ enableDemoSwitch: {
176
+ type: 'boolean',
177
+ title: 'Provide a demo switch path',
178
+ description:
179
+ `Registers a PUT handler and emits ${DEMO_SWITCH_PATH} so the ` +
180
+ 'switch widget can be tested without real switch hardware.',
181
+ default: true
182
+ }
183
+ }
184
+ }),
185
+
186
+ start(options) {
187
+ running = true
188
+ mountAssets()
189
+ registerProvider()
190
+ if (!options || options.enableDemoSwitch !== false) {
191
+ startDemoSwitch()
192
+ }
193
+ debug('started')
194
+ },
195
+
196
+ stop() {
197
+ running = false
198
+ debug('stopped')
199
+ }
200
+ }
201
+ }
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Instrument Setup</title>
7
+ <link rel="stylesheet" href="instruments.css">
8
+ </head>
9
+ <body class="panel">
10
+ <div id="root"></div>
11
+ <script src="js/config.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Display Value</title>
7
+ <link rel="stylesheet" href="instruments.css">
8
+ </head>
9
+ <body class="widget">
10
+ <div id="root"></div>
11
+ <script src="js/display.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Gauge</title>
7
+ <link rel="stylesheet" href="instruments.css">
8
+ </head>
9
+ <body class="widget">
10
+ <div id="root"></div>
11
+ <script src="js/gauge.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head><meta charset="utf-8"><title>Instrument Widgets</title>
4
+ <link rel="stylesheet" href="instruments.css"></head>
5
+ <body class="panel">
6
+ <div id="root">
7
+ <h2>Instrument Widgets</h2>
8
+ <p class="status">This package provides gauge, meter, switch and display
9
+ widgets for chartplotters that support the Signal K
10
+ <code>plotterExtensions</code> resource type (e.g. Freeboard-SK). There is
11
+ nothing to configure here: in your chartplotter, press and hold an empty
12
+ widget area to add a widget, and press and hold a placed widget to
13
+ configure it.</p>
14
+ </div>
15
+ </body>
16
+ </html>
@@ -0,0 +1,248 @@
1
+ /* Shared styling for instrument widgets and the config panel. Widgets render
2
+ over the chart, so backgrounds stay translucent-dark for contrast on both
3
+ light and dark map styles. */
4
+
5
+ html,
6
+ body {
7
+ margin: 0;
8
+ padding: 0;
9
+ height: 100%;
10
+ font-family: 'Helvetica Neue', Arial, sans-serif;
11
+ -webkit-user-select: none;
12
+ user-select: none;
13
+ background: transparent;
14
+ overflow: hidden;
15
+ }
16
+
17
+ #root {
18
+ height: 100%;
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: center;
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ .widget-body {
26
+ background: rgba(20, 26, 32, 0.78);
27
+ border-radius: 10px;
28
+ }
29
+
30
+ /* Widgets fill their frame with one translucent card. */
31
+ body.widget #root {
32
+ background: rgba(20, 26, 32, 0.78);
33
+ border-radius: 10px;
34
+ color: #e8edf2;
35
+ }
36
+
37
+ body.widget svg {
38
+ width: 100%;
39
+ height: 100%;
40
+ }
41
+
42
+ svg .track {
43
+ fill: none;
44
+ stroke: rgba(255, 255, 255, 0.18);
45
+ stroke-width: 7;
46
+ stroke-linecap: round;
47
+ }
48
+ svg .fill {
49
+ fill: none;
50
+ stroke: #29b6f6;
51
+ stroke-width: 7;
52
+ stroke-linecap: round;
53
+ }
54
+ svg .needle {
55
+ stroke: #ffb300;
56
+ stroke-width: 2;
57
+ }
58
+ svg .hub {
59
+ fill: #ffb300;
60
+ }
61
+ svg .value {
62
+ fill: #ffffff;
63
+ font-size: 20px;
64
+ font-weight: 600;
65
+ text-anchor: middle;
66
+ }
67
+ svg .meter-value {
68
+ font-size: 28px;
69
+ }
70
+ svg .units {
71
+ fill: #9fb3c4;
72
+ font-size: 9px;
73
+ text-anchor: middle;
74
+ }
75
+ svg .label {
76
+ fill: #9fb3c4;
77
+ font-size: 10px;
78
+ text-anchor: middle;
79
+ }
80
+ svg .meter-label {
81
+ font-size: 12px;
82
+ }
83
+ svg .bound {
84
+ fill: #6c7f8e;
85
+ font-size: 8px;
86
+ text-anchor: middle;
87
+ }
88
+ svg .fillbar {
89
+ fill: #29b6f6;
90
+ }
91
+ svg rect.track {
92
+ fill: rgba(255, 255, 255, 0.18);
93
+ stroke: none;
94
+ }
95
+
96
+ /* Switch widget */
97
+ .switch {
98
+ display: flex;
99
+ flex-direction: column;
100
+ align-items: center;
101
+ gap: 6px;
102
+ color: #e8edf2;
103
+ cursor: pointer;
104
+ }
105
+ .switch-pill {
106
+ width: 76px;
107
+ height: 36px;
108
+ border-radius: 18px;
109
+ background: rgba(255, 255, 255, 0.18);
110
+ position: relative;
111
+ transition: background 0.15s;
112
+ }
113
+ .switch-knob {
114
+ width: 32px;
115
+ height: 32px;
116
+ border-radius: 16px;
117
+ background: #cfd8dc;
118
+ position: absolute;
119
+ top: 2px;
120
+ left: 2px;
121
+ transition: left 0.15s, background 0.15s;
122
+ }
123
+ .switch.on .switch-pill {
124
+ background: #2e7d32;
125
+ }
126
+ .switch.on .switch-knob {
127
+ left: 42px;
128
+ background: #ffffff;
129
+ }
130
+ .switch-state {
131
+ font-size: 20px;
132
+ font-weight: 600;
133
+ }
134
+ .switch.unknown .switch-state {
135
+ color: #6c7f8e;
136
+ }
137
+ .switch-label {
138
+ font-size: 13px;
139
+ color: #9fb3c4;
140
+ text-align: center;
141
+ max-width: 95%;
142
+ overflow: hidden;
143
+ text-overflow: ellipsis;
144
+ white-space: nowrap;
145
+ }
146
+
147
+ /* Display Value widget */
148
+ .display {
149
+ display: flex;
150
+ flex-direction: column;
151
+ align-items: center;
152
+ justify-content: center;
153
+ gap: 0;
154
+ color: #e8edf2;
155
+ text-align: center;
156
+ width: 100%;
157
+ padding: 2px;
158
+ box-sizing: border-box;
159
+ }
160
+ .display-top {
161
+ font-size: 13px;
162
+ color: #9fb3c4;
163
+ max-width: 97%;
164
+ overflow: hidden;
165
+ text-overflow: ellipsis;
166
+ white-space: nowrap;
167
+ }
168
+ .display-value {
169
+ font-size: 40px;
170
+ font-weight: 600;
171
+ line-height: 1.05;
172
+ }
173
+ .display-units {
174
+ font-size: 15px;
175
+ font-weight: 400;
176
+ color: #9fb3c4;
177
+ margin-left: 4px;
178
+ }
179
+ .display-bottom {
180
+ font-size: 22px;
181
+ color: #c3d2de;
182
+ max-width: 97%;
183
+ overflow: hidden;
184
+ text-overflow: ellipsis;
185
+ white-space: nowrap;
186
+ }
187
+
188
+ /* Config panel */
189
+ body.panel {
190
+ background: #1d242b;
191
+ color: #e8edf2;
192
+ overflow: auto;
193
+ }
194
+ body.panel #root {
195
+ display: block;
196
+ padding: 14px;
197
+ }
198
+ body.panel h2 {
199
+ margin: 0 0 12px;
200
+ font-size: 16px;
201
+ text-transform: capitalize;
202
+ }
203
+ body.panel .row {
204
+ display: flex;
205
+ align-items: center;
206
+ gap: 10px;
207
+ margin-bottom: 10px;
208
+ }
209
+ body.panel .row span {
210
+ flex: 0 0 110px;
211
+ font-size: 13px;
212
+ color: #9fb3c4;
213
+ }
214
+ body.panel input,
215
+ body.panel select {
216
+ flex: 1;
217
+ padding: 6px 8px;
218
+ border-radius: 6px;
219
+ border: 1px solid #39444d;
220
+ background: #12181d;
221
+ color: #e8edf2;
222
+ font-size: 13px;
223
+ min-width: 0;
224
+ }
225
+ body.panel .actions {
226
+ display: flex;
227
+ justify-content: flex-end;
228
+ gap: 8px;
229
+ margin-top: 14px;
230
+ }
231
+ body.panel button {
232
+ padding: 7px 16px;
233
+ border-radius: 6px;
234
+ border: 1px solid #39444d;
235
+ background: #2a333b;
236
+ color: #e8edf2;
237
+ font-size: 13px;
238
+ cursor: pointer;
239
+ }
240
+ body.panel button.primary {
241
+ background: #1976d2;
242
+ border-color: #1976d2;
243
+ }
244
+ body.panel .status {
245
+ font-size: 12px;
246
+ color: #9fb3c4;
247
+ min-height: 1em;
248
+ }