multi-gauge 0.1.0 → 0.2.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 +258 -40
- package/package.json +7 -2
- package/src/MultiGauge.js +165 -16
- package/src/gpu/GpuRuntime.js +13 -3
- package/src/gpu/Renderer.js +5 -4
- package/src/gpu/SceneBuilder.js +8 -7
- package/src/gpu/TextAtlas.js +26 -29
- package/src/index.js +1 -0
- package/src/interaction/GridEditor.js +54 -3
- package/src/theme.js +9 -2
- package/types/index.d.ts +51 -0
- package/src/assets/fonts/InterVariable.woff2 +0 -0
- package/src/assets/fonts/LICENSE.txt +0 -92
package/README.md
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
# MultiGauge
|
|
2
2
|
|
|
3
|
-
> A
|
|
3
|
+
> A fast WebGPU instrument panel for realtime signals.
|
|
4
4
|
|
|
5
|
-
MultiGauge
|
|
5
|
+
MultiGauge displays multiple telemetry gauges in a responsive grid. It is a JavaScript ESM package with no runtime dependencies and accepts data from any source, including WebSocket, SSE, browser sensors and simulations.
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- A browser with WebGPU support.
|
|
12
|
+
- A canvas with a visible width and height.
|
|
13
|
+
- A secure context when required by the browser.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install multi-gauge
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```html
|
|
24
|
+
<canvas id="instruments"></canvas>
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
#instruments {
|
|
28
|
+
display: block;
|
|
29
|
+
width: 100%;
|
|
30
|
+
height: 600px;
|
|
31
|
+
}
|
|
32
|
+
</style>
|
|
33
|
+
```
|
|
34
|
+
|
|
9
35
|
```js
|
|
10
36
|
import { MultiGauge } from 'multi-gauge';
|
|
11
37
|
|
|
12
|
-
const panel = await MultiGauge.create(document.querySelector('
|
|
38
|
+
const panel = await MultiGauge.create(document.querySelector('#instruments'), {
|
|
13
39
|
grid: { rows: 2, columns: 3, gap: 8 },
|
|
14
40
|
accent: '#00eaff',
|
|
15
41
|
gauges: [
|
|
@@ -22,95 +48,289 @@ const panel = await MultiGauge.create(document.querySelector('canvas'), {
|
|
|
22
48
|
unit: 'km/h',
|
|
23
49
|
bands: [
|
|
24
50
|
{ from: 0, to: 160, kind: 'normal' },
|
|
25
|
-
{ from: 240, to: 300, kind: 'critical' }
|
|
51
|
+
{ from: 240, to: 300, kind: 'critical', label: 'LIMIT' }
|
|
26
52
|
],
|
|
27
|
-
markers: [
|
|
53
|
+
markers: [
|
|
54
|
+
{ id: 'target', value: 145, kind: 'target', label: 'TARGET' }
|
|
55
|
+
]
|
|
28
56
|
},
|
|
29
|
-
{
|
|
57
|
+
{
|
|
58
|
+
id: 'temperature',
|
|
59
|
+
type: 'linear',
|
|
60
|
+
label: 'TEMPERATURE',
|
|
61
|
+
min: -40,
|
|
62
|
+
max: 150,
|
|
63
|
+
unit: '°C'
|
|
64
|
+
},
|
|
65
|
+
{ id: 'heading', type: 'compass', label: 'HEADING' },
|
|
30
66
|
{ id: 'radar', type: 'status', label: 'RADAR' }
|
|
31
67
|
]
|
|
32
68
|
});
|
|
33
69
|
|
|
34
|
-
panel.update({
|
|
70
|
+
panel.update({
|
|
71
|
+
speed: 127,
|
|
72
|
+
temperature: 86,
|
|
73
|
+
heading: 182,
|
|
74
|
+
radar: true
|
|
75
|
+
});
|
|
35
76
|
```
|
|
36
77
|
|
|
37
|
-
##
|
|
78
|
+
## Gauge types
|
|
79
|
+
|
|
80
|
+
| Type | Use |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `arc` | A scalar value displayed on a radial scale. |
|
|
83
|
+
| `linear` | A horizontal or vertical scalar gauge. |
|
|
84
|
+
| `compass` | A cyclic heading. Values wrap between `min` and `max`. |
|
|
85
|
+
| `status` | A boolean, numeric or string state. |
|
|
86
|
+
|
|
87
|
+
Every gauge requires a unique `id`. Common options include:
|
|
38
88
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
89
|
+
| Option | Description |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `label` | Primary gauge label. Defaults to `id`. |
|
|
92
|
+
| `info` | Optional secondary information. |
|
|
93
|
+
| `unit` | Unit displayed with the value. |
|
|
94
|
+
| `min`, `max` | Numeric range. Defaults to `0` and `100`; compass defaults to `0..360`. |
|
|
95
|
+
| `value` | Initial value. |
|
|
96
|
+
| `row`, `col` | Zero-based grid position. Omit both for automatic placement. |
|
|
97
|
+
| `rowSpan`, `colSpan` | Number of occupied grid cells. Defaults to `1`. |
|
|
98
|
+
| `bands` | Semantic ranges for arc, linear and compass gauges. |
|
|
99
|
+
| `markers` | Fixed reference values for arc, linear and compass gauges. |
|
|
43
100
|
|
|
44
|
-
|
|
101
|
+
### Linear gauges
|
|
45
102
|
|
|
46
|
-
|
|
103
|
+
```js
|
|
104
|
+
{
|
|
105
|
+
id: 'fuel',
|
|
106
|
+
type: 'linear',
|
|
107
|
+
orientation: 'horizontal', // "horizontal" or "vertical"
|
|
108
|
+
mode: 'fill-marker', // "fill", "marker" or "fill-marker"
|
|
109
|
+
label: 'FUEL',
|
|
110
|
+
min: 0,
|
|
111
|
+
max: 100,
|
|
112
|
+
unit: '%'
|
|
113
|
+
}
|
|
114
|
+
```
|
|
47
115
|
|
|
48
|
-
|
|
116
|
+
### Compass gauges
|
|
49
117
|
|
|
50
|
-
|
|
118
|
+
```js
|
|
119
|
+
{
|
|
120
|
+
id: 'heading',
|
|
121
|
+
type: 'compass',
|
|
122
|
+
label: 'HEADING',
|
|
123
|
+
min: 0,
|
|
124
|
+
max: 360
|
|
125
|
+
}
|
|
126
|
+
```
|
|
51
127
|
|
|
52
|
-
|
|
128
|
+
Compass bands may cross the wrap point:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
bands: [{ from: 330, to: 30, kind: 'warning', label: 'SECTOR' }]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Status gauges
|
|
135
|
+
|
|
136
|
+
Status gauges use `states` to map incoming values to labels and semantic colors:
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
{
|
|
140
|
+
id: 'mode',
|
|
141
|
+
type: 'status',
|
|
142
|
+
label: 'MODE',
|
|
143
|
+
states: [
|
|
144
|
+
{ value: 0, label: 'OFF', kind: 'inactive' },
|
|
145
|
+
{ value: 1, label: 'READY', kind: 'normal' },
|
|
146
|
+
{ value: 2, label: 'FAULT', kind: 'critical' }
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Without an explicit `states` array, status gauges accept `false` as `OFF` and `true` as `ON`.
|
|
152
|
+
|
|
153
|
+
## Colors and themes
|
|
154
|
+
|
|
155
|
+
The panel accent colors live values and progress indicators. Bands, markers and states use their `kind`:
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
import { MultiGauge, SEMANTIC_KINDS } from 'multi-gauge';
|
|
159
|
+
|
|
160
|
+
console.log(SEMANTIC_KINDS);
|
|
161
|
+
// ["normal", "warning", "critical", "inactive", "target"]
|
|
162
|
+
|
|
163
|
+
const panel = await MultiGauge.create(canvas, {
|
|
164
|
+
accent: '#00eaff',
|
|
165
|
+
theme: {
|
|
166
|
+
background: '#071014',
|
|
167
|
+
text: '#e7f7fa',
|
|
168
|
+
normal: '#41e6a1',
|
|
169
|
+
warning: '#ffc857',
|
|
170
|
+
critical: '#ff4d6d',
|
|
171
|
+
inactive: '#40515a',
|
|
172
|
+
target: '#f3f7a7'
|
|
173
|
+
},
|
|
174
|
+
gauges: []
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Additional theme keys can be used as custom `kind` values.
|
|
179
|
+
|
|
180
|
+
Use `setAccents()` when individual gauges need source-owned runtime colors:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
panel.setAccents({
|
|
184
|
+
speed: '#4b9cff',
|
|
185
|
+
temperature: '#ff8c42'
|
|
186
|
+
});
|
|
187
|
+
```
|
|
53
188
|
|
|
54
|
-
|
|
189
|
+
Per-gauge runtime accents are not included in serialized state.
|
|
190
|
+
|
|
191
|
+
## Grid and header
|
|
55
192
|
|
|
56
193
|
```js
|
|
57
194
|
const panel = await MultiGauge.create(canvas, {
|
|
58
|
-
grid: { rows: 3, columns: 3 },
|
|
195
|
+
grid: { rows: 3, columns: 3, gap: 8 },
|
|
59
196
|
header: {
|
|
60
197
|
icon: new URL('./vehicle.svg', import.meta.url),
|
|
61
198
|
title: 'VEHICLE 01',
|
|
62
|
-
subtitle: 'Prototype',
|
|
199
|
+
subtitle: 'Prototype telemetry',
|
|
63
200
|
badge: 'ONLINE'
|
|
64
201
|
},
|
|
65
202
|
gauges: []
|
|
66
203
|
});
|
|
67
204
|
```
|
|
68
205
|
|
|
69
|
-
|
|
206
|
+
Header icons can be SVG, PNG or WebP URLs. Change the grid at runtime with:
|
|
70
207
|
|
|
71
|
-
|
|
208
|
+
```js
|
|
209
|
+
panel.setGrid({ rows: 4, columns: 4, gap: 8 });
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Updating values and configuration
|
|
72
213
|
|
|
73
214
|
```js
|
|
74
215
|
panel.set('speed', 130);
|
|
75
216
|
panel.update({ speed: 132, heading: 184 });
|
|
76
217
|
panel.setMarker('speed', 'target', 150);
|
|
77
218
|
|
|
78
|
-
panel.
|
|
79
|
-
|
|
219
|
+
panel.configure('speed', {
|
|
220
|
+
type: 'linear',
|
|
221
|
+
orientation: 'horizontal',
|
|
222
|
+
mode: 'fill-marker'
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
panel.add({
|
|
226
|
+
id: 'pressure',
|
|
227
|
+
type: 'linear',
|
|
228
|
+
label: 'PRESSURE',
|
|
229
|
+
min: 0,
|
|
230
|
+
max: 300,
|
|
231
|
+
unit: 'bar'
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
panel.remove('pressure');
|
|
80
235
|
```
|
|
81
236
|
|
|
82
|
-
|
|
237
|
+
`set(id, value)` reports an unknown gauge. `update(values)` ignores unknown keys, which allows a telemetry source to continue publishing while gauges are added or removed.
|
|
238
|
+
|
|
239
|
+
## Editing and selection
|
|
83
240
|
|
|
84
|
-
|
|
241
|
+
Editing is enabled by default. Users can:
|
|
242
|
+
|
|
243
|
+
- Drag a gauge to move it.
|
|
244
|
+
- Drag from an edge or corner to resize it.
|
|
245
|
+
- Select a gauge with a click.
|
|
246
|
+
- Remove a gauge with its `×` control.
|
|
247
|
+
- Press `Escape` to cancel a move or resize.
|
|
85
248
|
|
|
86
249
|
```js
|
|
250
|
+
panel.setEditing(false);
|
|
251
|
+
panel.setEditing(true);
|
|
252
|
+
|
|
87
253
|
panel.move('speed', 0, 1);
|
|
88
254
|
panel.resize('speed', 2, 2);
|
|
89
255
|
panel.maximize('speed');
|
|
90
256
|
panel.restoreGauge('speed');
|
|
91
257
|
panel.minimize('speed');
|
|
258
|
+
panel.select('speed');
|
|
259
|
+
panel.select(null);
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Events
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
panel.addEventListener('configurationchange', (event) => {
|
|
266
|
+
const { reason, state } = event.detail;
|
|
267
|
+
saveDashboard(state);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
panel.addEventListener('selectionchange', (event) => {
|
|
271
|
+
const { id, gauge } = event.detail;
|
|
272
|
+
openGaugeEditor(id, gauge);
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`configurationchange` is emitted after gauges are added, removed or configured and after grid or layout operations. Value updates, marker updates and runtime accents do not emit it. `selectionchange` is emitted when the selected gauge changes.
|
|
277
|
+
|
|
278
|
+
## Persistence
|
|
92
279
|
|
|
93
|
-
|
|
94
|
-
panel.
|
|
280
|
+
```js
|
|
281
|
+
const saved = JSON.stringify(panel.serialize());
|
|
282
|
+
|
|
283
|
+
// Later
|
|
284
|
+
panel.restore(JSON.parse(saved));
|
|
95
285
|
```
|
|
96
286
|
|
|
97
|
-
|
|
287
|
+
Serialized state contains the grid, header, theme, gauge configuration and current gauge values. Runtime accents and the current selection are not persisted.
|
|
98
288
|
|
|
99
|
-
|
|
289
|
+
## Fonts
|
|
100
290
|
|
|
101
|
-
|
|
291
|
+
MultiGauge uses the canvas' computed `font-family`. Define application fonts in CSS, or pass an explicit CSS font-family value:
|
|
102
292
|
|
|
103
293
|
```js
|
|
104
|
-
|
|
105
|
-
panel.restore(JSON.parse(json));
|
|
294
|
+
await document.fonts.ready;
|
|
106
295
|
|
|
107
|
-
|
|
108
|
-
|
|
296
|
+
const panel = await MultiGauge.create(canvas, {
|
|
297
|
+
fontFamily: '"Inter Variable", Inter, sans-serif',
|
|
298
|
+
gauges: []
|
|
299
|
+
});
|
|
109
300
|
```
|
|
110
301
|
|
|
111
|
-
|
|
302
|
+
MultiGauge does not include or download font files.
|
|
303
|
+
|
|
304
|
+
## API
|
|
305
|
+
|
|
306
|
+
| Method | Result |
|
|
307
|
+
| --- | --- |
|
|
308
|
+
| `MultiGauge.create(canvas, options)` | Creates a panel asynchronously. |
|
|
309
|
+
| `add(configuration)` | Adds and returns a gauge definition. |
|
|
310
|
+
| `remove(id)` | Removes a gauge and returns whether it existed. |
|
|
311
|
+
| `set(id, value)` | Updates one gauge. |
|
|
312
|
+
| `update(values)` | Updates several gauges. |
|
|
313
|
+
| `setMarker(gaugeId, markerId, value, options?)` | Adds or updates a marker. |
|
|
314
|
+
| `configure(id, patch)` | Updates a gauge definition. |
|
|
315
|
+
| `setAccents(accents)` | Applies transient per-gauge accent colors. |
|
|
316
|
+
| `setGrid(configuration)` | Changes grid rows, columns or gap. |
|
|
317
|
+
| `setEditing(enabled)` | Enables or disables direct editing. |
|
|
318
|
+
| `select(id)` | Selects a gauge, or clears selection with `null`. |
|
|
319
|
+
| `move(id, row, col)` | Moves a gauge. |
|
|
320
|
+
| `resize(id, rowSpan, colSpan)` | Resizes a gauge. |
|
|
321
|
+
| `maximize(id)` | Maximizes a gauge inside its grid. |
|
|
322
|
+
| `minimize(id)` | Minimizes a maximized gauge. |
|
|
323
|
+
| `restoreGauge(id)` | Restores the previous gauge placement. |
|
|
324
|
+
| `serialize()` | Returns JSON-compatible panel state. |
|
|
325
|
+
| `restore(state)` | Replaces the panel configuration. |
|
|
326
|
+
| `getStats()` | Returns diagnostic counters. |
|
|
327
|
+
| `destroy()` | Releases the panel and its browser resources. |
|
|
328
|
+
|
|
329
|
+
Call `destroy()` when the canvas is permanently removed:
|
|
112
330
|
|
|
113
|
-
|
|
331
|
+
```js
|
|
332
|
+
panel.destroy();
|
|
333
|
+
```
|
|
114
334
|
|
|
115
335
|
## Development
|
|
116
336
|
|
|
@@ -119,6 +339,4 @@ npm test
|
|
|
119
339
|
npm run demo
|
|
120
340
|
```
|
|
121
341
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
MultiGauge is source-agnostic: call `set()` or `update()` from WebSocket, SSE, sensors, shared memory, audio, simulations, or manual controls. Transport adapters belong outside this core package.
|
|
342
|
+
The demo is available at `http://localhost:8080/demo/` and the benchmark at `http://localhost:8080/benchmark/`.
|
package/package.json
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "multi-gauge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A tiny, fast WebGPU instrument panel for realtime signals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".":
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./types/index.d.ts",
|
|
9
|
+
"default": "./src/index.js"
|
|
10
|
+
}
|
|
8
11
|
},
|
|
12
|
+
"types": "./types/index.d.ts",
|
|
9
13
|
"files": [
|
|
10
14
|
"src",
|
|
15
|
+
"types",
|
|
11
16
|
"docs/multigauge-demo.png",
|
|
12
17
|
"README.md",
|
|
13
18
|
"LICENSE"
|
package/src/MultiGauge.js
CHANGED
|
@@ -8,7 +8,7 @@ import { GridEditor } from './interaction/GridEditor.js';
|
|
|
8
8
|
import { resolveTheme } from './theme.js';
|
|
9
9
|
|
|
10
10
|
/** A source-agnostic WebGPU panel containing multiple realtime gauges. */
|
|
11
|
-
export class MultiGauge {
|
|
11
|
+
export class MultiGauge extends EventTarget {
|
|
12
12
|
#canvas;
|
|
13
13
|
#runtime;
|
|
14
14
|
#renderer;
|
|
@@ -26,7 +26,10 @@ export class MultiGauge {
|
|
|
26
26
|
#height = 1;
|
|
27
27
|
#panelLayout = layoutPanel({ width: 1, height: 1 });
|
|
28
28
|
#editor;
|
|
29
|
+
#editing = true;
|
|
29
30
|
#previewEntries;
|
|
31
|
+
#selectedGaugeId = null;
|
|
32
|
+
#accents = {};
|
|
30
33
|
#stats = { updatesReceived: 0, rendersCoalesced: 0 };
|
|
31
34
|
|
|
32
35
|
/** Create a panel after the shared WebGPU runtime is ready. */
|
|
@@ -34,13 +37,17 @@ export class MultiGauge {
|
|
|
34
37
|
if (!canvas || typeof canvas.getContext !== 'function') {
|
|
35
38
|
throw new MultiGaugeError('MultiGauge.create() requires a canvas.');
|
|
36
39
|
}
|
|
37
|
-
const
|
|
40
|
+
const inheritedFontFamily = globalThis.getComputedStyle?.(canvas).fontFamily;
|
|
41
|
+
const runtime = await SharedGpuRuntime.get({
|
|
42
|
+
fontFamily: options.fontFamily || inheritedFontFamily
|
|
43
|
+
});
|
|
38
44
|
const panel = new MultiGauge(canvas, options, runtime);
|
|
39
45
|
panel.#initialize();
|
|
40
46
|
return panel;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
constructor(canvas, options, runtime) {
|
|
50
|
+
super();
|
|
44
51
|
this.#canvas = canvas;
|
|
45
52
|
this.#runtime = runtime;
|
|
46
53
|
this.#layout = new GridLayout(options.grid);
|
|
@@ -58,6 +65,7 @@ export class MultiGauge {
|
|
|
58
65
|
this.#assertAlive();
|
|
59
66
|
const gauge = this.#addNow(configuration);
|
|
60
67
|
this.#invalidate(true);
|
|
68
|
+
this.#emitConfigurationChange('add');
|
|
61
69
|
return { ...gauge };
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -68,7 +76,12 @@ export class MultiGauge {
|
|
|
68
76
|
return false;
|
|
69
77
|
}
|
|
70
78
|
this.#layout.remove(id);
|
|
79
|
+
delete this.#accents[id];
|
|
80
|
+
if (this.#selectedGaugeId === id) {
|
|
81
|
+
this.#selectNow(null);
|
|
82
|
+
}
|
|
71
83
|
this.#invalidate(true);
|
|
84
|
+
this.#emitConfigurationChange('remove');
|
|
72
85
|
return true;
|
|
73
86
|
}
|
|
74
87
|
|
|
@@ -89,11 +102,25 @@ export class MultiGauge {
|
|
|
89
102
|
return this;
|
|
90
103
|
}
|
|
91
104
|
|
|
92
|
-
/** Set
|
|
105
|
+
/** Set known signals; unknown stream fields are ignored after gauges are removed. */
|
|
93
106
|
update(values) {
|
|
94
107
|
this.#assertAlive();
|
|
108
|
+
let changed = false;
|
|
95
109
|
for (const [id, value] of Object.entries(values)) {
|
|
96
|
-
this.
|
|
110
|
+
const gauge = this.#gauges.get(id);
|
|
111
|
+
if (!gauge) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
this.#stats.updatesReceived += 1;
|
|
115
|
+
const next = coerceValue(gauge, value);
|
|
116
|
+
if (!Object.is(next, gauge.value)) {
|
|
117
|
+
gauge.value = next;
|
|
118
|
+
this.#dynamicDirty = true;
|
|
119
|
+
changed = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (changed) {
|
|
123
|
+
this.#schedule();
|
|
97
124
|
}
|
|
98
125
|
return this;
|
|
99
126
|
}
|
|
@@ -110,11 +137,48 @@ export class MultiGauge {
|
|
|
110
137
|
return this;
|
|
111
138
|
}
|
|
112
139
|
|
|
140
|
+
/** Atomically update one gauge definition without changing its placement. */
|
|
141
|
+
configure(id, patch = {}) {
|
|
142
|
+
this.#assertAlive();
|
|
143
|
+
const current = this.#gauges.get(id);
|
|
144
|
+
if (!current) {
|
|
145
|
+
throw new MultiGaugeError(`Unknown gauge: ${id}.`);
|
|
146
|
+
}
|
|
147
|
+
const next = normalizeGauge({ ...current, ...patch, id });
|
|
148
|
+
this.#gauges.set(id, next);
|
|
149
|
+
this.#invalidate(true);
|
|
150
|
+
this.#emitConfigurationChange('configure');
|
|
151
|
+
return { ...next };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Apply source-owned runtime accent colors without serializing them. */
|
|
155
|
+
setAccents(accents = {}) {
|
|
156
|
+
this.#assertAlive();
|
|
157
|
+
const next = Object.fromEntries(Object.entries(accents)
|
|
158
|
+
.filter(([id, value]) => this.#gauges.has(id) && typeof value === 'string' && value));
|
|
159
|
+
if (JSON.stringify(next) !== JSON.stringify(this.#accents)) {
|
|
160
|
+
this.#accents = next;
|
|
161
|
+
this.#invalidate(true);
|
|
162
|
+
}
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Select a gauge for an external editor. Selection is transient. */
|
|
167
|
+
select(id = null) {
|
|
168
|
+
this.#assertAlive();
|
|
169
|
+
if (id !== null && !this.#gauges.has(id)) {
|
|
170
|
+
throw new MultiGaugeError(`Unknown gauge: ${id}.`);
|
|
171
|
+
}
|
|
172
|
+
this.#selectNow(id);
|
|
173
|
+
return this;
|
|
174
|
+
}
|
|
175
|
+
|
|
113
176
|
move(id, row, col) {
|
|
114
177
|
this.#assertAlive();
|
|
115
178
|
const result = this.#layout.move(id, row, col);
|
|
116
179
|
if (result) {
|
|
117
180
|
this.#invalidate(true);
|
|
181
|
+
this.#emitConfigurationChange('move');
|
|
118
182
|
}
|
|
119
183
|
return Boolean(result);
|
|
120
184
|
}
|
|
@@ -124,6 +188,7 @@ export class MultiGauge {
|
|
|
124
188
|
const result = this.#layout.resize(id, rowSpan, colSpan);
|
|
125
189
|
if (result) {
|
|
126
190
|
this.#invalidate(true);
|
|
191
|
+
this.#emitConfigurationChange('resize');
|
|
127
192
|
}
|
|
128
193
|
return Boolean(result);
|
|
129
194
|
}
|
|
@@ -132,6 +197,7 @@ export class MultiGauge {
|
|
|
132
197
|
this.#assertAlive();
|
|
133
198
|
this.#layout.maximize(id);
|
|
134
199
|
this.#invalidate(true);
|
|
200
|
+
this.#emitConfigurationChange('maximize');
|
|
135
201
|
return this;
|
|
136
202
|
}
|
|
137
203
|
|
|
@@ -140,6 +206,7 @@ export class MultiGauge {
|
|
|
140
206
|
const result = this.#layout.minimize(id);
|
|
141
207
|
if (result) {
|
|
142
208
|
this.#invalidate(true);
|
|
209
|
+
this.#emitConfigurationChange('minimize');
|
|
143
210
|
}
|
|
144
211
|
return Boolean(result);
|
|
145
212
|
}
|
|
@@ -149,23 +216,60 @@ export class MultiGauge {
|
|
|
149
216
|
const result = this.#layout.restore(id);
|
|
150
217
|
if (result) {
|
|
151
218
|
this.#invalidate(true);
|
|
219
|
+
this.#emitConfigurationChange('restore-gauge');
|
|
152
220
|
}
|
|
153
221
|
return Boolean(result);
|
|
154
222
|
}
|
|
155
223
|
|
|
224
|
+
/** Atomically resize the grid and deterministically reflow gauges that no longer fit. */
|
|
225
|
+
setGrid(configuration = {}) {
|
|
226
|
+
this.#assertAlive();
|
|
227
|
+
const current = this.#layout.config;
|
|
228
|
+
const next = new GridLayout({ ...current, ...configuration });
|
|
229
|
+
const entries = new Map(this.#layout.entries());
|
|
230
|
+
const ordered = [...this.#gauges.values()].sort((a, b) => {
|
|
231
|
+
const first = entries.get(a.id);
|
|
232
|
+
const second = entries.get(b.id);
|
|
233
|
+
return first.row - second.row || first.col - second.col || a.id.localeCompare(b.id);
|
|
234
|
+
});
|
|
235
|
+
for (const gauge of ordered) {
|
|
236
|
+
const placement = entries.get(gauge.id);
|
|
237
|
+
try {
|
|
238
|
+
next.add(gauge.id, placement);
|
|
239
|
+
} catch {
|
|
240
|
+
next.add(gauge.id, {
|
|
241
|
+
rowSpan: placement.rowSpan,
|
|
242
|
+
colSpan: placement.colSpan
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
this.#replaceLayout(next);
|
|
247
|
+
this.#invalidate(true);
|
|
248
|
+
this.#emitConfigurationChange('grid');
|
|
249
|
+
return this;
|
|
250
|
+
}
|
|
251
|
+
|
|
156
252
|
/** Enable or disable direct layout manipulation. Enabled by default. */
|
|
157
253
|
setEditing(enabled) {
|
|
158
254
|
this.#assertAlive();
|
|
255
|
+
this.#editing = Boolean(enabled);
|
|
159
256
|
if (enabled && !this.#editor) {
|
|
160
257
|
this.#editor = new GridEditor(this.#canvas, this.#layout, {
|
|
161
258
|
preview: (entries) => this.#setLayoutPreview(entries),
|
|
162
|
-
commit: () =>
|
|
163
|
-
|
|
259
|
+
commit: () => {
|
|
260
|
+
this.#finishLayoutPreview();
|
|
261
|
+
this.#emitConfigurationChange('layout');
|
|
262
|
+
},
|
|
263
|
+
cancel: () => this.#finishLayoutPreview(),
|
|
264
|
+
select: (id) => this.#selectNow(id),
|
|
265
|
+
remove: (id) => this.remove(id)
|
|
164
266
|
});
|
|
267
|
+
this.#editor.select(this.#selectedGaugeId);
|
|
165
268
|
this.#refreshEditor();
|
|
166
269
|
} else if (!enabled && this.#editor) {
|
|
167
270
|
this.#editor.destroy();
|
|
168
271
|
this.#editor = null;
|
|
272
|
+
this.#selectNow(null);
|
|
169
273
|
}
|
|
170
274
|
return this;
|
|
171
275
|
}
|
|
@@ -202,18 +306,18 @@ export class MultiGauge {
|
|
|
202
306
|
layout.add(gauge.id, gauge);
|
|
203
307
|
gauges.set(gauge.id, gauge);
|
|
204
308
|
}
|
|
205
|
-
this.#layout = layout;
|
|
206
|
-
this.#previewEntries = undefined;
|
|
207
309
|
this.#gauges = gauges;
|
|
310
|
+
this.#accents = Object.fromEntries(Object.entries(this.#accents)
|
|
311
|
+
.filter(([id]) => gauges.has(id)));
|
|
312
|
+
if (this.#selectedGaugeId && !gauges.has(this.#selectedGaugeId)) {
|
|
313
|
+
this.#selectNow(null);
|
|
314
|
+
}
|
|
208
315
|
this.#accent = state.accent ?? '#00eaff';
|
|
209
316
|
this.#theme = resolveTheme(state.theme, this.#accent);
|
|
210
317
|
this.#header = state.header ? { ...state.header } : null;
|
|
211
|
-
|
|
212
|
-
this.#editor.destroy();
|
|
213
|
-
this.#editor = null;
|
|
214
|
-
this.setEditing(true);
|
|
215
|
-
}
|
|
318
|
+
this.#replaceLayout(layout);
|
|
216
319
|
this.#invalidate(true);
|
|
320
|
+
this.#emitConfigurationChange('restore');
|
|
217
321
|
return this;
|
|
218
322
|
}
|
|
219
323
|
|
|
@@ -260,7 +364,7 @@ export class MultiGauge {
|
|
|
260
364
|
this.#resizeObserver.observe(this.#canvas);
|
|
261
365
|
}
|
|
262
366
|
resize();
|
|
263
|
-
this.setEditing(
|
|
367
|
+
this.setEditing(this.#editing);
|
|
264
368
|
this.#invalidate(true);
|
|
265
369
|
}
|
|
266
370
|
|
|
@@ -314,10 +418,17 @@ export class MultiGauge {
|
|
|
314
418
|
gauges = [maximized];
|
|
315
419
|
}
|
|
316
420
|
if (this.#staticDirty) {
|
|
317
|
-
this.#renderer.rebuildStatic(
|
|
421
|
+
this.#renderer.rebuildStatic(
|
|
422
|
+
gauges,
|
|
423
|
+
rectangles,
|
|
424
|
+
this.#header,
|
|
425
|
+
this.#theme,
|
|
426
|
+
this.#panelLayout,
|
|
427
|
+
this.#accents
|
|
428
|
+
);
|
|
318
429
|
}
|
|
319
430
|
if (this.#dynamicDirty) {
|
|
320
|
-
this.#renderer.updateDynamic(gauges, this.#theme);
|
|
431
|
+
this.#renderer.updateDynamic(gauges, this.#theme, this.#accents);
|
|
321
432
|
}
|
|
322
433
|
this.#renderer.render(this.#theme);
|
|
323
434
|
this.#staticDirty = false;
|
|
@@ -337,6 +448,44 @@ export class MultiGauge {
|
|
|
337
448
|
this.#invalidate(true);
|
|
338
449
|
}
|
|
339
450
|
|
|
451
|
+
#replaceLayout(layout) {
|
|
452
|
+
this.#layout = layout;
|
|
453
|
+
this.#previewEntries = undefined;
|
|
454
|
+
if (!this.#editor) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
this.#editor.destroy();
|
|
458
|
+
this.#editor = null;
|
|
459
|
+
if (this.#editing) {
|
|
460
|
+
this.setEditing(true);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
#selectNow(id) {
|
|
465
|
+
const next = id ?? null;
|
|
466
|
+
if (this.#selectedGaugeId === next) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
this.#selectedGaugeId = next;
|
|
470
|
+
this.#editor?.select(next);
|
|
471
|
+
const gauge = next ? this.#gauges.get(next) : null;
|
|
472
|
+
this.dispatchEvent(new CustomEvent('selectionchange', {
|
|
473
|
+
detail: {
|
|
474
|
+
id: next,
|
|
475
|
+
gauge: gauge ? serializeGauge(gauge, this.#layout.get(next)) : null
|
|
476
|
+
}
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#emitConfigurationChange(reason) {
|
|
481
|
+
this.dispatchEvent(new CustomEvent('configurationchange', {
|
|
482
|
+
detail: {
|
|
483
|
+
reason,
|
|
484
|
+
state: this.serialize()
|
|
485
|
+
}
|
|
486
|
+
}));
|
|
487
|
+
}
|
|
488
|
+
|
|
340
489
|
#refreshEditor(rectangles) {
|
|
341
490
|
if (!this.#editor) {
|
|
342
491
|
return;
|
package/src/gpu/GpuRuntime.js
CHANGED
|
@@ -19,14 +19,18 @@ export class SharedGpuRuntime {
|
|
|
19
19
|
#generation = 0;
|
|
20
20
|
#recovering = false;
|
|
21
21
|
|
|
22
|
-
static get() {
|
|
22
|
+
static get(options = {}) {
|
|
23
23
|
if (!this.#promise) {
|
|
24
|
-
const runtime = new SharedGpuRuntime();
|
|
24
|
+
const runtime = new SharedGpuRuntime(options);
|
|
25
25
|
this.#promise = runtime.#initialize().then(() => runtime);
|
|
26
26
|
}
|
|
27
27
|
return this.#promise;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.#fontFamily = options.fontFamily;
|
|
32
|
+
}
|
|
33
|
+
|
|
30
34
|
get device() {
|
|
31
35
|
return this.#device;
|
|
32
36
|
}
|
|
@@ -82,7 +86,11 @@ export class SharedGpuRuntime {
|
|
|
82
86
|
this.#device = await this.#adapter.requestDevice();
|
|
83
87
|
this.#format = navigator.gpu.getPreferredCanvasFormat();
|
|
84
88
|
this.#createSharedResources();
|
|
85
|
-
this.#atlas = new TextAtlas(
|
|
89
|
+
this.#atlas = new TextAtlas(
|
|
90
|
+
this.#device,
|
|
91
|
+
globalThis.devicePixelRatio || 1,
|
|
92
|
+
this.#fontFamily
|
|
93
|
+
);
|
|
86
94
|
await this.#atlas.initialize();
|
|
87
95
|
this.#icons = new IconCache(this.#device);
|
|
88
96
|
this.#generation += 1;
|
|
@@ -150,4 +158,6 @@ export class SharedGpuRuntime {
|
|
|
150
158
|
this.#recovering = false;
|
|
151
159
|
}
|
|
152
160
|
}
|
|
161
|
+
|
|
162
|
+
#fontFamily;
|
|
153
163
|
}
|
package/src/gpu/Renderer.js
CHANGED
|
@@ -82,14 +82,15 @@ export class Renderer {
|
|
|
82
82
|
return changed;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
rebuildStatic(gauges, rectangles, header, theme, panelLayout) {
|
|
85
|
+
rebuildStatic(gauges, rectangles, header, theme, panelLayout, accents = {}) {
|
|
86
86
|
const scene = buildStaticScene(
|
|
87
87
|
this.#runtime.atlas,
|
|
88
88
|
gauges,
|
|
89
89
|
rectangles,
|
|
90
90
|
header,
|
|
91
91
|
theme,
|
|
92
|
-
panelLayout
|
|
92
|
+
panelLayout,
|
|
93
|
+
accents
|
|
93
94
|
);
|
|
94
95
|
this.#shapeCount = scene.shapes.length / 20;
|
|
95
96
|
this.#staticTextCount = scene.text.length / TEXT_INSTANCE_SIZE;
|
|
@@ -105,12 +106,12 @@ export class Renderer {
|
|
|
105
106
|
this.#setIcon(header?.icon, theme, scene.iconRect);
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
updateDynamic(gauges, theme) {
|
|
109
|
+
updateDynamic(gauges, theme, accents = {}) {
|
|
109
110
|
const valueLength = Math.max(8, gauges.length * 8);
|
|
110
111
|
if (this.#dynamicValuesStaging.length !== valueLength) {
|
|
111
112
|
this.#dynamicValuesStaging = new Float32Array(valueLength);
|
|
112
113
|
}
|
|
113
|
-
buildDynamicValues(gauges, theme, this.#dynamicValuesStaging);
|
|
114
|
+
buildDynamicValues(gauges, theme, this.#dynamicValuesStaging, accents);
|
|
114
115
|
const dynamicChanged = this.#ensure('dynamic', valueLength * 4, STORAGE());
|
|
115
116
|
this.#write('dynamic', this.#dynamicValuesStaging);
|
|
116
117
|
|
package/src/gpu/SceneBuilder.js
CHANGED
|
@@ -489,7 +489,7 @@ function addStatus(scene, rect, theme, index) {
|
|
|
489
489
|
[5, radius, 0, 0], [0, index, 6, 0]);
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
-
export function buildStaticScene(atlas, gauges, rectangles, header, theme, panelLayout) {
|
|
492
|
+
export function buildStaticScene(atlas, gauges, rectangles, header, theme, panelLayout, accents = {}) {
|
|
493
493
|
const scene = { shapes: [], text: [], atlas, cellLayouts: new Map(), iconRect: null };
|
|
494
494
|
const { headerRect } = panelLayout;
|
|
495
495
|
if (header && headerRect.height > 0) {
|
|
@@ -546,6 +546,7 @@ export function buildStaticScene(atlas, gauges, rectangles, header, theme, panel
|
|
|
546
546
|
if (!rect) {
|
|
547
547
|
return;
|
|
548
548
|
}
|
|
549
|
+
const gaugeTheme = accents[gauge.id] ? { ...theme, accent: accents[gauge.id] } : theme;
|
|
549
550
|
const layout = absoluteCellLayout(rect, gauge);
|
|
550
551
|
scene.cellLayouts.set(gauge.id, layout);
|
|
551
552
|
box(scene.shapes, rect.x, rect.y, rect.width, rect.height, color(theme.surface), 7);
|
|
@@ -573,13 +574,13 @@ export function buildStaticScene(atlas, gauges, rectangles, header, theme, panel
|
|
|
573
574
|
}
|
|
574
575
|
|
|
575
576
|
if (gauge.type === 'arc') {
|
|
576
|
-
addArcGauge(scene, gauge, layout.gaugeRect,
|
|
577
|
+
addArcGauge(scene, gauge, layout.gaugeRect, gaugeTheme, index, layout);
|
|
577
578
|
} else if (gauge.type === 'linear') {
|
|
578
|
-
addLinearGauge(scene, gauge, layout.gaugeRect,
|
|
579
|
+
addLinearGauge(scene, gauge, layout.gaugeRect, gaugeTheme, index, layout);
|
|
579
580
|
} else if (gauge.type === 'compass') {
|
|
580
|
-
addCompass(scene, gauge, layout.gaugeRect,
|
|
581
|
+
addCompass(scene, gauge, layout.gaugeRect, gaugeTheme, index, layout);
|
|
581
582
|
} else {
|
|
582
|
-
addStatus(scene, layout.gaugeRect,
|
|
583
|
+
addStatus(scene, layout.gaugeRect, gaugeTheme, index);
|
|
583
584
|
}
|
|
584
585
|
});
|
|
585
586
|
return scene;
|
|
@@ -614,7 +615,7 @@ export function dynamicTextKey(gauges) {
|
|
|
614
615
|
}
|
|
615
616
|
|
|
616
617
|
/** Write the small per-gauge value/color records without allocating an intermediate array. */
|
|
617
|
-
export function buildDynamicValues(gauges, theme, output = []) {
|
|
618
|
+
export function buildDynamicValues(gauges, theme, output = [], accents = {}) {
|
|
618
619
|
const length = Math.max(8, gauges.length * 8);
|
|
619
620
|
if (Array.isArray(output)) {
|
|
620
621
|
output.length = length;
|
|
@@ -622,8 +623,8 @@ export function buildDynamicValues(gauges, theme, output = []) {
|
|
|
622
623
|
throw new RangeError('Dynamic value output is smaller than required.');
|
|
623
624
|
}
|
|
624
625
|
let offset = 0;
|
|
625
|
-
const accent = color(theme.accent);
|
|
626
626
|
for (const gauge of gauges) {
|
|
627
|
+
const accent = color(accents[gauge.id] ?? theme.accent);
|
|
627
628
|
const normalized = gauge.type === 'status' ? 0 : normalizeValue(gauge, gauge.value);
|
|
628
629
|
const status = gauge.type === 'status' ? resolveStatus(gauge, gauge.value) : null;
|
|
629
630
|
const rgba = status
|
package/src/gpu/TextAtlas.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
const
|
|
2
|
-
const FONT_URL = new URL('../assets/fonts/InterVariable.woff2', import.meta.url);
|
|
1
|
+
const DEFAULT_FONT_FAMILY = 'sans-serif';
|
|
3
2
|
const CHARACTERS = [...new Set(
|
|
4
3
|
Array.from({ length: 95 }, (_, index) => String.fromCharCode(index + 32)).join('')
|
|
5
4
|
+ '°…µ·'
|
|
@@ -33,15 +32,23 @@ function makeCanvas(width, height) {
|
|
|
33
32
|
: Object.assign(document.createElement('canvas'), { width, height });
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
function
|
|
37
|
-
|
|
35
|
+
function normalizeFontFamily(value) {
|
|
36
|
+
const family = String(value ?? '').trim() || DEFAULT_FONT_FAMILY;
|
|
37
|
+
if (family.includes(',') || family.includes('"') || family.includes("'") || !family.includes(' ')) {
|
|
38
|
+
return family;
|
|
39
|
+
}
|
|
40
|
+
return `"${family}"`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fontString(style, pixelRatio, fontFamily) {
|
|
44
|
+
return `${style.weight} ${style.logicalSize * pixelRatio}px ${fontFamily}`;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
function finiteMetric(value, fallback = 0) {
|
|
41
48
|
return Number.isFinite(value) ? value : fallback;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
/** A DPR-aware bitmap atlas
|
|
51
|
+
/** A DPR-aware bitmap atlas using the font family supplied by the host application. */
|
|
45
52
|
export class TextAtlas {
|
|
46
53
|
static CHARACTERS = CHARACTERS;
|
|
47
54
|
static STYLES = STYLE_DEFINITIONS;
|
|
@@ -56,11 +63,12 @@ export class TextAtlas {
|
|
|
56
63
|
#styleCache = new Map();
|
|
57
64
|
#metricsCache = new Map();
|
|
58
65
|
#measureCache = new Map();
|
|
59
|
-
#
|
|
66
|
+
#fontFamily;
|
|
60
67
|
|
|
61
|
-
constructor(device, pixelRatio = globalThis.devicePixelRatio || 1) {
|
|
68
|
+
constructor(device, pixelRatio = globalThis.devicePixelRatio || 1, fontFamily = DEFAULT_FONT_FAMILY) {
|
|
62
69
|
this.#device = device;
|
|
63
70
|
this.#pixelRatio = Math.max(1, Number(pixelRatio) || 1);
|
|
71
|
+
this.#fontFamily = normalizeFontFamily(fontFamily);
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
get view() {
|
|
@@ -138,7 +146,7 @@ export class TextAtlas {
|
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
async initialize() {
|
|
141
|
-
await this.#
|
|
149
|
+
await this.#waitForFont();
|
|
142
150
|
const measurementCanvas = makeCanvas(1, 1);
|
|
143
151
|
const measurement = measurementCanvas.getContext('2d', { alpha: true });
|
|
144
152
|
measurement.textBaseline = 'alphabetic';
|
|
@@ -179,7 +187,7 @@ export class TextAtlas {
|
|
|
179
187
|
context.textBaseline = 'alphabetic';
|
|
180
188
|
context.fontKerning = 'none';
|
|
181
189
|
for (const style of styles) {
|
|
182
|
-
context.font = fontString(style, this.#pixelRatio);
|
|
190
|
+
context.font = fontString(style, this.#pixelRatio, this.#fontFamily);
|
|
183
191
|
for (const [character, glyph] of style.glyphs) {
|
|
184
192
|
if (character !== ' ') {
|
|
185
193
|
context.fillText(
|
|
@@ -201,7 +209,7 @@ export class TextAtlas {
|
|
|
201
209
|
}
|
|
202
210
|
|
|
203
211
|
this.#texture = this.#device.createTexture({
|
|
204
|
-
label: `MultiGauge
|
|
212
|
+
label: `MultiGauge glyph atlas (${this.#fontFamily}) @${this.#pixelRatio}x`,
|
|
205
213
|
size: [atlasWidth, atlasHeight],
|
|
206
214
|
format: 'rgba8unorm',
|
|
207
215
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
|
|
@@ -226,34 +234,23 @@ export class TextAtlas {
|
|
|
226
234
|
this.#styleCache.clear();
|
|
227
235
|
this.#metricsCache.clear();
|
|
228
236
|
this.#measureCache.clear();
|
|
229
|
-
if (this.#fontFace && globalThis.document?.fonts) {
|
|
230
|
-
document.fonts.delete(this.#fontFace);
|
|
231
|
-
}
|
|
232
237
|
}
|
|
233
238
|
|
|
234
|
-
async #
|
|
235
|
-
if (
|
|
236
|
-
|
|
239
|
+
async #waitForFont() {
|
|
240
|
+
if (!globalThis.document?.fonts) {
|
|
241
|
+
return;
|
|
237
242
|
}
|
|
238
|
-
const face = new FontFace(FONT_FAMILY, `url("${FONT_URL.href}") format("woff2")`, {
|
|
239
|
-
style: 'normal',
|
|
240
|
-
weight: '100 900'
|
|
241
|
-
});
|
|
242
|
-
this.#fontFace = await face.load();
|
|
243
|
-
document.fonts.add(this.#fontFace);
|
|
244
243
|
await Promise.all([...new Set(STYLE_DEFINITIONS.map(({ weight }) => weight))]
|
|
245
|
-
.map((weight) => document.fonts.load(
|
|
244
|
+
.map((weight) => document.fonts.load(
|
|
245
|
+
`${weight} 16px ${this.#fontFamily}`,
|
|
246
|
+
'Hgm0123°µ·'
|
|
247
|
+
)));
|
|
246
248
|
await document.fonts.ready;
|
|
247
|
-
for (const weight of new Set(STYLE_DEFINITIONS.map((style) => style.weight))) {
|
|
248
|
-
if (!document.fonts.check(`${weight} 16px "${FONT_FAMILY}"`, 'Hgm0123°µ·')) {
|
|
249
|
-
throw new Error(`Inter Variable weight ${weight} did not load for the MultiGauge atlas.`);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
249
|
}
|
|
253
250
|
|
|
254
251
|
#measureStyle(context, definition, padding) {
|
|
255
252
|
const style = { ...definition, glyphs: new Map() };
|
|
256
|
-
context.font = fontString(style, this.#pixelRatio);
|
|
253
|
+
context.font = fontString(style, this.#pixelRatio, this.#fontFamily);
|
|
257
254
|
const records = [];
|
|
258
255
|
for (const character of CHARACTERS) {
|
|
259
256
|
const metrics = context.measureText(character);
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@ export class GridEditor {
|
|
|
23
23
|
#frame;
|
|
24
24
|
#parentPosition;
|
|
25
25
|
#panelLayout;
|
|
26
|
+
#selectedId = null;
|
|
26
27
|
|
|
27
28
|
constructor(canvas, layout, actions) {
|
|
28
29
|
this.#canvas = canvas;
|
|
@@ -62,6 +63,7 @@ export class GridEditor {
|
|
|
62
63
|
this.#overlay.addEventListener('pointermove', this.#onPointerMove);
|
|
63
64
|
this.#overlay.addEventListener('pointerup', this.#onPointerUp);
|
|
64
65
|
this.#overlay.addEventListener('pointercancel', this.#onPointerCancel);
|
|
66
|
+
this.#overlay.addEventListener('click', this.#onClick);
|
|
65
67
|
window.addEventListener('keydown', this.#onKeyDown);
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -97,7 +99,17 @@ export class GridEditor {
|
|
|
97
99
|
if (!this.#drag) {
|
|
98
100
|
item.style.transform = 'translate3d(0, 0, 0)';
|
|
99
101
|
}
|
|
100
|
-
item.style.borderColor = this.#drag?.id === id
|
|
102
|
+
item.style.borderColor = this.#drag?.id === id || this.#selectedId === id
|
|
103
|
+
? '#00eaff'
|
|
104
|
+
: 'transparent';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Highlight one gauge without changing the layout. */
|
|
109
|
+
select(id) {
|
|
110
|
+
this.#selectedId = id ?? null;
|
|
111
|
+
for (const [gaugeId, item] of this.#nodes) {
|
|
112
|
+
item.style.borderColor = gaugeId === this.#selectedId ? '#00eaff' : 'transparent';
|
|
101
113
|
}
|
|
102
114
|
}
|
|
103
115
|
|
|
@@ -108,6 +120,7 @@ export class GridEditor {
|
|
|
108
120
|
this.#overlay.removeEventListener('pointermove', this.#onPointerMove);
|
|
109
121
|
this.#overlay.removeEventListener('pointerup', this.#onPointerUp);
|
|
110
122
|
this.#overlay.removeEventListener('pointercancel', this.#onPointerCancel);
|
|
123
|
+
this.#overlay.removeEventListener('click', this.#onClick);
|
|
111
124
|
window.removeEventListener('keydown', this.#onKeyDown);
|
|
112
125
|
this.#overlay.remove();
|
|
113
126
|
if (parent) {
|
|
@@ -128,11 +141,38 @@ export class GridEditor {
|
|
|
128
141
|
willChange: 'transform',
|
|
129
142
|
contain: 'layout style paint'
|
|
130
143
|
});
|
|
144
|
+
const remove = document.createElement('button');
|
|
145
|
+
remove.type = 'button';
|
|
146
|
+
remove.dataset.gaugeRemove = '';
|
|
147
|
+
remove.title = `Remove ${id}`;
|
|
148
|
+
remove.setAttribute('aria-label', remove.title);
|
|
149
|
+
remove.textContent = '×';
|
|
150
|
+
Object.assign(remove.style, {
|
|
151
|
+
position: 'absolute',
|
|
152
|
+
top: '4px',
|
|
153
|
+
right: '4px',
|
|
154
|
+
zIndex: '2',
|
|
155
|
+
width: '24px',
|
|
156
|
+
height: '24px',
|
|
157
|
+
padding: '0',
|
|
158
|
+
border: '0',
|
|
159
|
+
borderRadius: '0',
|
|
160
|
+
color: 'rgba(220, 239, 243, 0.72)',
|
|
161
|
+
background: 'transparent',
|
|
162
|
+
appearance: 'none',
|
|
163
|
+
font: 'inherit',
|
|
164
|
+
fontSize: '20px',
|
|
165
|
+
fontWeight: '400',
|
|
166
|
+
lineHeight: '1',
|
|
167
|
+
textShadow: '0 1px 2px rgba(0, 0, 0, 0.9)',
|
|
168
|
+
cursor: 'pointer'
|
|
169
|
+
});
|
|
170
|
+
item.append(remove);
|
|
131
171
|
return item;
|
|
132
172
|
}
|
|
133
173
|
|
|
134
174
|
#onPointerDown = (event) => {
|
|
135
|
-
if (this.#drag || event.button !== 0) {
|
|
175
|
+
if (this.#drag || event.button !== 0 || event.target.closest?.('[data-gauge-remove]')) {
|
|
136
176
|
return;
|
|
137
177
|
}
|
|
138
178
|
const item = event.target.closest('[data-gauge-id]');
|
|
@@ -182,6 +222,15 @@ export class GridEditor {
|
|
|
182
222
|
event.preventDefault();
|
|
183
223
|
};
|
|
184
224
|
|
|
225
|
+
#onClick = (event) => {
|
|
226
|
+
const button = event.target.closest?.('[data-gauge-remove]');
|
|
227
|
+
const id = button?.closest?.('[data-gauge-id]')?.dataset.gaugeId;
|
|
228
|
+
if (!id) return;
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
event.stopPropagation();
|
|
231
|
+
this.#actions.remove?.(id);
|
|
232
|
+
};
|
|
233
|
+
|
|
185
234
|
#onPointerMove = (event) => {
|
|
186
235
|
if (!this.#drag) {
|
|
187
236
|
const item = event.target.closest?.('[data-gauge-id]');
|
|
@@ -328,8 +377,10 @@ export class GridEditor {
|
|
|
328
377
|
return;
|
|
329
378
|
}
|
|
330
379
|
if (!this.#drag.previewed) {
|
|
380
|
+
const id = this.#drag.id;
|
|
331
381
|
this.#drag.session.cancel();
|
|
332
382
|
this.#finishDrag(null);
|
|
383
|
+
this.#actions.select?.(id);
|
|
333
384
|
event.preventDefault();
|
|
334
385
|
return;
|
|
335
386
|
}
|
|
@@ -379,7 +430,7 @@ export class GridEditor {
|
|
|
379
430
|
drag.item.style.transformOrigin = '';
|
|
380
431
|
drag.item.style.cursor = 'grab';
|
|
381
432
|
drag.item.style.zIndex = '';
|
|
382
|
-
drag.item.style.borderColor = 'transparent';
|
|
433
|
+
drag.item.style.borderColor = drag.id === this.#selectedId ? '#00eaff' : 'transparent';
|
|
383
434
|
drag.item.style.background = 'transparent';
|
|
384
435
|
this.#placeholder.style.display = 'none';
|
|
385
436
|
this.#drag = null;
|
package/src/theme.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
export const SEMANTIC_KINDS = Object.freeze([
|
|
2
|
+
'normal',
|
|
3
|
+
'warning',
|
|
4
|
+
'critical',
|
|
5
|
+
'inactive',
|
|
6
|
+
'target'
|
|
7
|
+
]);
|
|
8
|
+
|
|
1
9
|
export const DEFAULT_THEME = Object.freeze({
|
|
2
10
|
background: '#071014',
|
|
3
11
|
surface: '#0c171d',
|
|
@@ -9,8 +17,7 @@ export const DEFAULT_THEME = Object.freeze({
|
|
|
9
17
|
warning: '#ffc857',
|
|
10
18
|
critical: '#ff4d6d',
|
|
11
19
|
inactive: '#40515a',
|
|
12
|
-
target: '#f3f7a7'
|
|
13
|
-
cruise: '#6b8cff'
|
|
20
|
+
target: '#f3f7a7'
|
|
14
21
|
});
|
|
15
22
|
|
|
16
23
|
/** Parse #rgb, #rrggbb, #rrggbbaa or a numeric RGBA array. */
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type MultiGaugeGrid = {
|
|
2
|
+
rows?: number;
|
|
3
|
+
columns?: number;
|
|
4
|
+
gap?: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const SEMANTIC_KINDS: readonly [
|
|
8
|
+
'normal',
|
|
9
|
+
'warning',
|
|
10
|
+
'critical',
|
|
11
|
+
'inactive',
|
|
12
|
+
'target'
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export type MultiGaugeState = {
|
|
16
|
+
version?: number;
|
|
17
|
+
grid?: MultiGaugeGrid;
|
|
18
|
+
accent?: string;
|
|
19
|
+
theme?: Record<string, unknown>;
|
|
20
|
+
header?: Record<string, unknown> | null;
|
|
21
|
+
gauges?: Array<Record<string, any>>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type MultiGaugeOptions = MultiGaugeState & {
|
|
25
|
+
fontFamily?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class MultiGaugeError extends Error {}
|
|
29
|
+
|
|
30
|
+
export class MultiGauge extends EventTarget {
|
|
31
|
+
static create(canvas: HTMLCanvasElement, options?: MultiGaugeOptions): Promise<MultiGauge>;
|
|
32
|
+
add(configuration: Record<string, any>): Record<string, any>;
|
|
33
|
+
remove(id: string): boolean;
|
|
34
|
+
set(id: string, value: unknown): this;
|
|
35
|
+
update(values: Record<string, unknown>): this;
|
|
36
|
+
setMarker(gaugeId: string, markerId: string, value: unknown, options?: Record<string, any>): this;
|
|
37
|
+
configure(id: string, patch?: Record<string, any>): Record<string, any>;
|
|
38
|
+
setAccents(accents?: Record<string, string>): this;
|
|
39
|
+
select(id?: string | null): this;
|
|
40
|
+
move(id: string, row: number, col: number): boolean;
|
|
41
|
+
resize(id: string, rowSpan: number, colSpan: number): boolean;
|
|
42
|
+
maximize(id: string): this;
|
|
43
|
+
minimize(id: string): boolean;
|
|
44
|
+
restoreGauge(id: string): boolean;
|
|
45
|
+
setGrid(configuration?: MultiGaugeGrid): this;
|
|
46
|
+
setEditing(enabled: boolean): this;
|
|
47
|
+
serialize(): Record<string, any>;
|
|
48
|
+
restore(state: MultiGaugeState): this;
|
|
49
|
+
getStats(): Record<string, number>;
|
|
50
|
+
destroy(): void;
|
|
51
|
+
}
|
|
Binary file
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
|
2
|
-
|
|
3
|
-
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
-
This license is copied below, and is also available with a FAQ at:
|
|
5
|
-
http://scripts.sil.org/OFL
|
|
6
|
-
|
|
7
|
-
-----------------------------------------------------------
|
|
8
|
-
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
9
|
-
-----------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
PREAMBLE
|
|
12
|
-
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
13
|
-
development of collaborative font projects, to support the font creation
|
|
14
|
-
efforts of academic and linguistic communities, and to provide a free and
|
|
15
|
-
open framework in which fonts may be shared and improved in partnership
|
|
16
|
-
with others.
|
|
17
|
-
|
|
18
|
-
The OFL allows the licensed fonts to be used, studied, modified and
|
|
19
|
-
redistributed freely as long as they are not sold by themselves. The
|
|
20
|
-
fonts, including any derivative works, can be bundled, embedded,
|
|
21
|
-
redistributed and/or sold with any software provided that any reserved
|
|
22
|
-
names are not used by derivative works. The fonts and derivatives,
|
|
23
|
-
however, cannot be released under any other type of license. The
|
|
24
|
-
requirement for fonts to remain under this license does not apply
|
|
25
|
-
to any document created using the fonts or their derivatives.
|
|
26
|
-
|
|
27
|
-
DEFINITIONS
|
|
28
|
-
"Font Software" refers to the set of files released by the Copyright
|
|
29
|
-
Holder(s) under this license and clearly marked as such. This may
|
|
30
|
-
include source files, build scripts and documentation.
|
|
31
|
-
|
|
32
|
-
"Reserved Font Name" refers to any names specified as such after the
|
|
33
|
-
copyright statement(s).
|
|
34
|
-
|
|
35
|
-
"Original Version" refers to the collection of Font Software components as
|
|
36
|
-
distributed by the Copyright Holder(s).
|
|
37
|
-
|
|
38
|
-
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
39
|
-
or substituting -- in part or in whole -- any of the components of the
|
|
40
|
-
Original Version, by changing formats or by porting the Font Software to a
|
|
41
|
-
new environment.
|
|
42
|
-
|
|
43
|
-
"Author" refers to any designer, engineer, programmer, technical
|
|
44
|
-
writer or other person who contributed to the Font Software.
|
|
45
|
-
|
|
46
|
-
PERMISSION AND CONDITIONS
|
|
47
|
-
Permission is hereby granted, free of charge, to any person obtaining
|
|
48
|
-
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
49
|
-
redistribute, and sell modified and unmodified copies of the Font
|
|
50
|
-
Software, subject to the following conditions:
|
|
51
|
-
|
|
52
|
-
1) Neither the Font Software nor any of its individual components,
|
|
53
|
-
in Original or Modified Versions, may be sold by itself.
|
|
54
|
-
|
|
55
|
-
2) Original or Modified Versions of the Font Software may be bundled,
|
|
56
|
-
redistributed and/or sold with any software, provided that each copy
|
|
57
|
-
contains the above copyright notice and this license. These can be
|
|
58
|
-
included either as stand-alone text files, human-readable headers or
|
|
59
|
-
in the appropriate machine-readable metadata fields within text or
|
|
60
|
-
binary files as long as those fields can be easily viewed by the user.
|
|
61
|
-
|
|
62
|
-
3) No Modified Version of the Font Software may use the Reserved Font
|
|
63
|
-
Name(s) unless explicit written permission is granted by the corresponding
|
|
64
|
-
Copyright Holder. This restriction only applies to the primary font name as
|
|
65
|
-
presented to the users.
|
|
66
|
-
|
|
67
|
-
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
68
|
-
Software shall not be used to promote, endorse or advertise any
|
|
69
|
-
Modified Version, except to acknowledge the contribution(s) of the
|
|
70
|
-
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
71
|
-
permission.
|
|
72
|
-
|
|
73
|
-
5) The Font Software, modified or unmodified, in part or in whole,
|
|
74
|
-
must be distributed entirely under this license, and must not be
|
|
75
|
-
distributed under any other license. The requirement for fonts to
|
|
76
|
-
remain under this license does not apply to any document created
|
|
77
|
-
using the Font Software.
|
|
78
|
-
|
|
79
|
-
TERMINATION
|
|
80
|
-
This license becomes null and void if any of the above conditions are
|
|
81
|
-
not met.
|
|
82
|
-
|
|
83
|
-
DISCLAIMER
|
|
84
|
-
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
85
|
-
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
86
|
-
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
87
|
-
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
88
|
-
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
89
|
-
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
90
|
-
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
91
|
-
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
92
|
-
OTHER DEALINGS IN THE FONT SOFTWARE.
|