multi-gauge 0.1.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/LICENSE +21 -0
- package/README.md +124 -0
- package/docs/multigauge-demo.png +0 -0
- package/package.json +29 -0
- package/src/MultiGauge.js +358 -0
- package/src/assets/fonts/InterVariable.woff2 +0 -0
- package/src/assets/fonts/LICENSE.txt +92 -0
- package/src/errors.js +7 -0
- package/src/gpu/GpuRuntime.js +153 -0
- package/src/gpu/IconCache.js +69 -0
- package/src/gpu/Renderer.js +370 -0
- package/src/gpu/SceneBuilder.js +736 -0
- package/src/gpu/TextAtlas.js +304 -0
- package/src/gpu/shaders.js +212 -0
- package/src/index.js +2 -0
- package/src/interaction/GridEditor.js +446 -0
- package/src/layout/CellLayout.js +167 -0
- package/src/layout/GridLayout.js +405 -0
- package/src/layout/PanelLayout.js +70 -0
- package/src/model/GaugeModel.js +196 -0
- package/src/theme.js +41 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { MultiGaugeError } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
function positiveInteger(value, fallback) {
|
|
4
|
+
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function cloneEntries(entries) {
|
|
8
|
+
return new Map([...entries].map(([id, item]) => [id, { ...item }]));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function overlaps(a, b) {
|
|
12
|
+
return a.row < b.row + b.rowSpan
|
|
13
|
+
&& a.row + a.rowSpan > b.row
|
|
14
|
+
&& a.col < b.col + b.colSpan
|
|
15
|
+
&& a.col + a.colSpan > b.col;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sortItems(entries) {
|
|
19
|
+
return [...entries].sort(([idA, a], [idB, b]) => a.row - b.row
|
|
20
|
+
|| a.col - b.col
|
|
21
|
+
|| idA.localeCompare(idB));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A non-mutating drag/resize transaction over a committed GridLayout. */
|
|
25
|
+
export class GridDragSession {
|
|
26
|
+
#layout;
|
|
27
|
+
#id;
|
|
28
|
+
#mode;
|
|
29
|
+
#snapshot;
|
|
30
|
+
#preview;
|
|
31
|
+
#valid = true;
|
|
32
|
+
#active = true;
|
|
33
|
+
|
|
34
|
+
constructor(layout, id, mode = 'move') {
|
|
35
|
+
if (mode !== 'move' && mode !== 'resize') {
|
|
36
|
+
throw new MultiGaugeError(`Unknown grid transaction mode: ${mode}.`);
|
|
37
|
+
}
|
|
38
|
+
if (!layout.has(id)) {
|
|
39
|
+
throw new MultiGaugeError(`Unknown gauge: ${id}.`);
|
|
40
|
+
}
|
|
41
|
+
this.#layout = layout;
|
|
42
|
+
this.#id = id;
|
|
43
|
+
this.#mode = mode;
|
|
44
|
+
this.#snapshot = layout.snapshot();
|
|
45
|
+
this.#preview = cloneEntries(this.#snapshot);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get entries() {
|
|
49
|
+
return cloneEntries(this.#preview);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get placement() {
|
|
53
|
+
return { ...this.#preview.get(this.#id) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get valid() {
|
|
57
|
+
return this.#valid;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
preview(change) {
|
|
61
|
+
if (!this.#active) {
|
|
62
|
+
throw new MultiGaugeError('This grid transaction is no longer active.');
|
|
63
|
+
}
|
|
64
|
+
const current = this.#snapshot.get(this.#id);
|
|
65
|
+
const candidate = this.#mode === 'resize'
|
|
66
|
+
? {
|
|
67
|
+
...current,
|
|
68
|
+
row: Number.isInteger(change.row) ? change.row : current.row,
|
|
69
|
+
col: Number.isInteger(change.col) ? change.col : current.col,
|
|
70
|
+
rowSpan: positiveInteger(change.rowSpan, current.rowSpan),
|
|
71
|
+
colSpan: positiveInteger(change.colSpan, current.colSpan)
|
|
72
|
+
}
|
|
73
|
+
: {
|
|
74
|
+
...current,
|
|
75
|
+
row: change.row,
|
|
76
|
+
col: change.col
|
|
77
|
+
};
|
|
78
|
+
const next = this.#layout.previewPlacement(this.#id, candidate, this.#snapshot);
|
|
79
|
+
this.#valid = Boolean(next);
|
|
80
|
+
this.#preview = next ?? cloneEntries(this.#snapshot);
|
|
81
|
+
return {
|
|
82
|
+
valid: this.#valid,
|
|
83
|
+
entries: this.entries,
|
|
84
|
+
placement: this.placement
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
commit() {
|
|
89
|
+
if (!this.#active) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
this.#active = false;
|
|
93
|
+
if (!this.#valid) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return this.#layout.commit(this.#preview);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
cancel() {
|
|
100
|
+
if (!this.#active) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
this.#active = false;
|
|
104
|
+
this.#preview = cloneEntries(this.#snapshot);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Grid placement and geometry, independent from DOM and WebGPU. */
|
|
110
|
+
export class GridLayout {
|
|
111
|
+
#columns;
|
|
112
|
+
#rows;
|
|
113
|
+
#gap;
|
|
114
|
+
#items = new Map();
|
|
115
|
+
#saved = new Map();
|
|
116
|
+
|
|
117
|
+
constructor({ rows = 1, columns = 1, gap = 8 } = {}) {
|
|
118
|
+
this.#rows = positiveInteger(rows, 1);
|
|
119
|
+
this.#columns = positiveInteger(columns, 1);
|
|
120
|
+
this.#gap = Math.max(0, Number(gap) || 0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get config() {
|
|
124
|
+
return { rows: this.#rows, columns: this.#columns, gap: this.#gap };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get size() {
|
|
128
|
+
return this.#items.size;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
has(id) {
|
|
132
|
+
return this.#items.has(id);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get(id) {
|
|
136
|
+
const item = this.#items.get(id);
|
|
137
|
+
return item ? { ...item } : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
entries() {
|
|
141
|
+
return [...this.#items.entries()].map(([id, item]) => [id, { ...item }]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
snapshot() {
|
|
145
|
+
return cloneEntries(this.#items);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
beginDrag(id, mode = 'move') {
|
|
149
|
+
return new GridDragSession(this, id, mode);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
add(id, requested = {}) {
|
|
153
|
+
if (this.#items.has(id)) {
|
|
154
|
+
throw new MultiGaugeError(`Gauge id already exists: ${id}.`);
|
|
155
|
+
}
|
|
156
|
+
const rowSpan = positiveInteger(requested.rowSpan, 1);
|
|
157
|
+
const colSpan = positiveInteger(requested.colSpan, 1);
|
|
158
|
+
const explicit = Number.isInteger(requested.row) && Number.isInteger(requested.col);
|
|
159
|
+
const position = explicit
|
|
160
|
+
? { row: requested.row, col: requested.col, rowSpan, colSpan }
|
|
161
|
+
: this.#find(rowSpan, colSpan, this.#items);
|
|
162
|
+
if (!position || !this.#fits(position, this.#items)) {
|
|
163
|
+
throw new MultiGaugeError(`Gauge "${id}" does not fit in the grid.`);
|
|
164
|
+
}
|
|
165
|
+
this.#items.set(id, position);
|
|
166
|
+
return { ...position };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
remove(id) {
|
|
170
|
+
this.#saved.delete(id);
|
|
171
|
+
return this.#items.delete(id);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
move(id, row, col) {
|
|
175
|
+
const session = this.beginDrag(id, 'move');
|
|
176
|
+
const result = session.preview({ row, col });
|
|
177
|
+
if (!result.valid || !session.commit()) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return this.get(id);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
resize(id, rowSpan, colSpan) {
|
|
184
|
+
const session = this.beginDrag(id, 'resize');
|
|
185
|
+
const result = session.preview({ rowSpan, colSpan });
|
|
186
|
+
if (!result.valid || !session.commit()) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return this.get(id);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Resolve a candidate from a stable snapshot without changing committed state. */
|
|
193
|
+
previewPlacement(id, candidate, source = this.#items) {
|
|
194
|
+
const initial = source.get(id);
|
|
195
|
+
if (!initial || !this.#inBounds(candidate)) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const base = cloneEntries(source);
|
|
199
|
+
const collisions = sortItems(new Map([...base].filter(([otherId, item]) => otherId !== id
|
|
200
|
+
&& !item.maximized && overlaps(candidate, item))));
|
|
201
|
+
|
|
202
|
+
if (collisions.length === 0) {
|
|
203
|
+
base.set(id, { ...candidate });
|
|
204
|
+
return base;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (collisions.length === 1) {
|
|
208
|
+
const [otherId, other] = collisions[0];
|
|
209
|
+
const exactFootprint = candidate.row === other.row && candidate.col === other.col
|
|
210
|
+
&& candidate.rowSpan === other.rowSpan && candidate.colSpan === other.colSpan
|
|
211
|
+
&& initial.rowSpan === other.rowSpan && initial.colSpan === other.colSpan;
|
|
212
|
+
if (exactFootprint) {
|
|
213
|
+
base.set(id, { ...candidate });
|
|
214
|
+
base.set(otherId, { ...other, row: initial.row, col: initial.col });
|
|
215
|
+
if (this.#valid(base)) {
|
|
216
|
+
return base;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const partial = cloneEntries(base);
|
|
222
|
+
partial.delete(id);
|
|
223
|
+
for (const [otherId] of collisions) {
|
|
224
|
+
partial.delete(otherId);
|
|
225
|
+
}
|
|
226
|
+
partial.set(id, { ...candidate });
|
|
227
|
+
const rowDirection = Math.sign(candidate.row - initial.row);
|
|
228
|
+
const colDirection = Math.sign(candidate.col - initial.col);
|
|
229
|
+
let resolved = true;
|
|
230
|
+
for (const [otherId, item] of collisions) {
|
|
231
|
+
const position = this.#nearestPosition(item, partial, {
|
|
232
|
+
row: item.row + rowDirection * candidate.rowSpan,
|
|
233
|
+
col: item.col + colDirection * candidate.colSpan
|
|
234
|
+
});
|
|
235
|
+
if (!position) {
|
|
236
|
+
resolved = false;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
partial.set(otherId, { ...item, ...position });
|
|
240
|
+
}
|
|
241
|
+
if (resolved && this.#valid(partial)) {
|
|
242
|
+
return partial;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const compacted = new Map([[id, { ...candidate }]]);
|
|
246
|
+
for (const [otherId, item] of sortItems(new Map([...base].filter(([otherId]) => otherId !== id)))) {
|
|
247
|
+
const position = this.#find(item.rowSpan, item.colSpan, compacted);
|
|
248
|
+
if (!position) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
compacted.set(otherId, { ...item, ...position });
|
|
252
|
+
}
|
|
253
|
+
return this.#valid(compacted) ? compacted : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Atomically replace committed placements with a validated preview. */
|
|
257
|
+
commit(entries) {
|
|
258
|
+
if (!(entries instanceof Map) || entries.size !== this.#items.size
|
|
259
|
+
|| [...this.#items.keys()].some((id) => !entries.has(id))
|
|
260
|
+
|| !this.#valid(entries)) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
this.#items = cloneEntries(entries);
|
|
264
|
+
this.#saved.clear();
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
maximize(id) {
|
|
269
|
+
this.#remember(id);
|
|
270
|
+
const current = this.#require(id);
|
|
271
|
+
this.#items.set(id, { ...current, row: 0, col: 0, rowSpan: this.#rows, colSpan: this.#columns, maximized: true });
|
|
272
|
+
return this.get(id);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
minimize(id) {
|
|
276
|
+
this.#remember(id);
|
|
277
|
+
const current = this.#require(id);
|
|
278
|
+
const position = this.#find(1, 1, this.#items, id);
|
|
279
|
+
if (!position) {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
this.#items.set(id, { ...current, ...position, rowSpan: 1, colSpan: 1, minimized: true });
|
|
283
|
+
return this.get(id);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
restore(id) {
|
|
287
|
+
const saved = this.#saved.get(id);
|
|
288
|
+
if (!saved || !this.#fits(saved, this.#items, id)) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
this.#items.set(id, saved);
|
|
292
|
+
this.#saved.delete(id);
|
|
293
|
+
return this.get(id);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
occupancy(ignoreId, entries = this.#items) {
|
|
297
|
+
const cells = Array.from({ length: this.#rows }, () => Array(this.#columns).fill(null));
|
|
298
|
+
for (const [id, item] of entries) {
|
|
299
|
+
if (id === ignoreId || item.maximized) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
for (let row = item.row; row < item.row + item.rowSpan; row += 1) {
|
|
303
|
+
for (let col = item.col; col < item.col + item.colSpan; col += 1) {
|
|
304
|
+
if (cells[row]) {
|
|
305
|
+
cells[row][col] = id;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return cells;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
rectangles(gridRect, entries = this.#items) {
|
|
314
|
+
const width = Math.max(0, Number(gridRect?.width) || 0);
|
|
315
|
+
const height = Math.max(0, Number(gridRect?.height) || 0);
|
|
316
|
+
const originX = Number(gridRect?.x) || 0;
|
|
317
|
+
const originY = Number(gridRect?.y) || 0;
|
|
318
|
+
const cellWidth = Math.max(0, (width - this.#gap * (this.#columns - 1)) / this.#columns);
|
|
319
|
+
const cellHeight = Math.max(0, (height - this.#gap * (this.#rows - 1)) / this.#rows);
|
|
320
|
+
const result = new Map();
|
|
321
|
+
for (const [id, item] of entries) {
|
|
322
|
+
result.set(id, {
|
|
323
|
+
x: originX + item.col * (cellWidth + this.#gap),
|
|
324
|
+
y: originY + item.row * (cellHeight + this.#gap),
|
|
325
|
+
width: item.colSpan * cellWidth + (item.colSpan - 1) * this.#gap,
|
|
326
|
+
height: item.rowSpan * cellHeight + (item.rowSpan - 1) * this.#gap
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#require(id) {
|
|
333
|
+
const item = this.#items.get(id);
|
|
334
|
+
if (!item) {
|
|
335
|
+
throw new MultiGaugeError(`Unknown gauge: ${id}.`);
|
|
336
|
+
}
|
|
337
|
+
return item;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#remember(id) {
|
|
341
|
+
if (!this.#saved.has(id)) {
|
|
342
|
+
const current = this.#require(id);
|
|
343
|
+
const { maximized, minimized, ...plain } = current;
|
|
344
|
+
this.#saved.set(id, plain);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
#find(rowSpan, colSpan, entries, ignoreId) {
|
|
349
|
+
for (let row = 0; row <= this.#rows - rowSpan; row += 1) {
|
|
350
|
+
for (let col = 0; col <= this.#columns - colSpan; col += 1) {
|
|
351
|
+
const candidate = { row, col, rowSpan, colSpan };
|
|
352
|
+
if (this.#fits(candidate, entries, ignoreId)) {
|
|
353
|
+
return candidate;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#nearestPosition(item, entries, anchor) {
|
|
361
|
+
const candidates = [];
|
|
362
|
+
for (let row = 0; row <= this.#rows - item.rowSpan; row += 1) {
|
|
363
|
+
for (let col = 0; col <= this.#columns - item.colSpan; col += 1) {
|
|
364
|
+
const candidate = { row, col, rowSpan: item.rowSpan, colSpan: item.colSpan };
|
|
365
|
+
if (this.#fits(candidate, entries)) {
|
|
366
|
+
candidates.push(candidate);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
candidates.sort((a, b) => (Math.abs(a.row - anchor.row) + Math.abs(a.col - anchor.col))
|
|
371
|
+
- (Math.abs(b.row - anchor.row) + Math.abs(b.col - anchor.col))
|
|
372
|
+
|| a.row - b.row || a.col - b.col);
|
|
373
|
+
return candidates[0] ?? null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#inBounds(item) {
|
|
377
|
+
return Number.isInteger(item.row) && Number.isInteger(item.col)
|
|
378
|
+
&& positiveInteger(item.rowSpan, 0) === item.rowSpan
|
|
379
|
+
&& positiveInteger(item.colSpan, 0) === item.colSpan
|
|
380
|
+
&& item.row >= 0 && item.col >= 0
|
|
381
|
+
&& item.row + item.rowSpan <= this.#rows
|
|
382
|
+
&& item.col + item.colSpan <= this.#columns;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#fits(item, entries, ignoreId) {
|
|
386
|
+
return this.#inBounds(item) && ![...entries].some(([id, placed]) => id !== ignoreId
|
|
387
|
+
&& !placed.maximized && overlaps(item, placed));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#valid(entries) {
|
|
391
|
+
const items = [...entries];
|
|
392
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
393
|
+
const [, item] = items[index];
|
|
394
|
+
if (!this.#inBounds(item)) {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
for (let other = index + 1; other < items.length; other += 1) {
|
|
398
|
+
if (!item.maximized && !items[other][1].maximized && overlaps(item, items[other][1])) {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const HEADER_GAP = 8;
|
|
2
|
+
|
|
3
|
+
function dimension(value) {
|
|
4
|
+
return Math.max(0, Number(value) || 0);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Split a panel into mutually exclusive header and grid regions.
|
|
9
|
+
* Every value is expressed in CSS pixels local to the canvas content box.
|
|
10
|
+
*/
|
|
11
|
+
export function layoutPanel({ width, height, hasHeader = false } = {}) {
|
|
12
|
+
const panelWidth = dimension(width);
|
|
13
|
+
const panelHeight = dimension(height);
|
|
14
|
+
const preferredHeaderHeight = panelWidth < 300 ? 52 : 64;
|
|
15
|
+
const headerHeight = hasHeader
|
|
16
|
+
? Math.min(preferredHeaderHeight, Math.max(0, panelHeight - HEADER_GAP))
|
|
17
|
+
: 0;
|
|
18
|
+
const gap = headerHeight > 0 ? Math.min(HEADER_GAP, panelHeight - headerHeight) : 0;
|
|
19
|
+
const gridY = headerHeight + gap;
|
|
20
|
+
|
|
21
|
+
return Object.freeze({
|
|
22
|
+
panelRect: Object.freeze({ x: 0, y: 0, width: panelWidth, height: panelHeight }),
|
|
23
|
+
headerRect: Object.freeze({ x: 0, y: 0, width: panelWidth, height: headerHeight }),
|
|
24
|
+
gridRect: Object.freeze({
|
|
25
|
+
x: 0,
|
|
26
|
+
y: gridY,
|
|
27
|
+
width: panelWidth,
|
|
28
|
+
height: Math.max(0, panelHeight - gridY)
|
|
29
|
+
}),
|
|
30
|
+
gap
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Convert a panel-local rectangle to coordinates local to a containing rect. */
|
|
35
|
+
export function localRect(rect, container) {
|
|
36
|
+
return {
|
|
37
|
+
x: rect.x - container.x,
|
|
38
|
+
y: rect.y - container.y,
|
|
39
|
+
width: rect.width,
|
|
40
|
+
height: rect.height
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Header content geometry, also in panel-local CSS pixels. */
|
|
45
|
+
export function layoutHeader(headerRect, { hasIcon = false } = {}) {
|
|
46
|
+
const compact = headerRect.width < 300;
|
|
47
|
+
const paddingX = compact ? 14 : 18;
|
|
48
|
+
const paddingY = compact ? 9 : 10;
|
|
49
|
+
const iconSize = hasIcon ? (compact ? 18 : 20) : 0;
|
|
50
|
+
const textX = headerRect.x + paddingX + (hasIcon ? iconSize + 10 : 0);
|
|
51
|
+
const badgeWidth = Math.min(110, Math.max(0, headerRect.width * 0.24));
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
compact,
|
|
55
|
+
iconRect: hasIcon ? {
|
|
56
|
+
x: headerRect.x + paddingX,
|
|
57
|
+
y: headerRect.y + (headerRect.height - iconSize) / 2,
|
|
58
|
+
width: iconSize,
|
|
59
|
+
height: iconSize
|
|
60
|
+
} : null,
|
|
61
|
+
title: { x: textX, y: headerRect.y + paddingY, size: compact ? 14 : 16 },
|
|
62
|
+
subtitle: { x: textX, y: headerRect.y + paddingY + (compact ? 20 : 23), size: compact ? 10 : 11 },
|
|
63
|
+
badgeRect: {
|
|
64
|
+
x: headerRect.x + headerRect.width - paddingX - badgeWidth,
|
|
65
|
+
y: headerRect.y + (headerRect.height - 26) / 2,
|
|
66
|
+
width: badgeWidth,
|
|
67
|
+
height: 26
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { MultiGaugeError } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
export const GAUGE_TYPES = new Set(['arc', 'linear', 'compass', 'status']);
|
|
4
|
+
export const LINEAR_MODES = new Set(['fill', 'marker', 'fill-marker']);
|
|
5
|
+
export const ORIENTATIONS = new Set(['horizontal', 'vertical']);
|
|
6
|
+
|
|
7
|
+
const DEFAULT_STATUS_STATES = [
|
|
8
|
+
{ value: false, label: 'OFF', kind: 'inactive' },
|
|
9
|
+
{ value: true, label: 'ON', kind: 'normal' }
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function finite(value, fallback) {
|
|
13
|
+
return Number.isFinite(value) ? value : fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function cloneItems(items = []) {
|
|
17
|
+
return items.map((item) => ({ ...item }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Clamp a number to an inclusive range. */
|
|
21
|
+
export function clamp(value, min, max) {
|
|
22
|
+
return Math.min(max, Math.max(min, value));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Normalize a scalar to 0..1. Compass values wrap instead of clamping. */
|
|
26
|
+
export function normalizeValue(gauge, value) {
|
|
27
|
+
const number = Number(value);
|
|
28
|
+
if (!Number.isFinite(number)) {
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
if (gauge.type === 'compass') {
|
|
32
|
+
const range = gauge.max - gauge.min;
|
|
33
|
+
return range > 0 ? (((number - gauge.min) % range) + range) % range / range : 0;
|
|
34
|
+
}
|
|
35
|
+
return (clamp(number, gauge.min, gauge.max) - gauge.min) / (gauge.max - gauge.min);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Split a possibly wrapping band into monotonically increasing intervals. */
|
|
39
|
+
export function projectBand(band, min, max, cyclic = false) {
|
|
40
|
+
const from = clamp(finite(Number(band.from), min), min, max);
|
|
41
|
+
const to = clamp(finite(Number(band.to), max), min, max);
|
|
42
|
+
if (cyclic && from > to) {
|
|
43
|
+
return [[from, max], [min, to]];
|
|
44
|
+
}
|
|
45
|
+
return [[Math.min(from, to), Math.max(from, to)]];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolve the active band, giving later declarations precedence when bands overlap. */
|
|
49
|
+
export function resolveBand(gauge, value = gauge.value) {
|
|
50
|
+
if (gauge.type === 'status') {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const number = Number(value);
|
|
54
|
+
if (!Number.isFinite(number)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const current = gauge.type === 'compass'
|
|
58
|
+
? gauge.min + normalizeValue(gauge, number) * (gauge.max - gauge.min)
|
|
59
|
+
: clamp(number, gauge.min, gauge.max);
|
|
60
|
+
for (let index = gauge.bands.length - 1; index >= 0; index -= 1) {
|
|
61
|
+
const band = gauge.bands[index];
|
|
62
|
+
const segments = projectBand(band, gauge.min, gauge.max, gauge.type === 'compass');
|
|
63
|
+
if (segments.some(([from, to]) => current >= from && current <= to)) {
|
|
64
|
+
return band;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolve a public status value to its configured state. */
|
|
71
|
+
export function resolveStatus(gauge, value) {
|
|
72
|
+
const direct = gauge.states.find((state) => Object.is(state.value, value));
|
|
73
|
+
if (direct) {
|
|
74
|
+
return direct;
|
|
75
|
+
}
|
|
76
|
+
if (typeof value === 'boolean') {
|
|
77
|
+
const numeric = gauge.states.find((state) => state.value === Number(value));
|
|
78
|
+
if (numeric) {
|
|
79
|
+
return numeric;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (typeof value === 'number') {
|
|
83
|
+
const boolean = gauge.states.find((state) => typeof state.value === 'boolean'
|
|
84
|
+
&& Number(state.value) === value);
|
|
85
|
+
if (boolean) {
|
|
86
|
+
return boolean;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return gauge.states[0];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Convert public gauge configuration to the stable internal CPU model. */
|
|
93
|
+
export function normalizeGauge(input) {
|
|
94
|
+
if (!input || typeof input !== 'object') {
|
|
95
|
+
throw new MultiGaugeError('Gauge configuration must be an object.');
|
|
96
|
+
}
|
|
97
|
+
if (typeof input.id !== 'string' || input.id.length === 0) {
|
|
98
|
+
throw new MultiGaugeError('Every gauge needs a non-empty string id.');
|
|
99
|
+
}
|
|
100
|
+
const type = input.type ?? 'arc';
|
|
101
|
+
if (!GAUGE_TYPES.has(type)) {
|
|
102
|
+
throw new MultiGaugeError(`Unknown gauge type: ${type}.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const min = finite(Number(input.min), type === 'compass' ? 0 : 0);
|
|
106
|
+
const max = finite(Number(input.max), type === 'compass' ? 360 : 100);
|
|
107
|
+
if (max <= min) {
|
|
108
|
+
throw new MultiGaugeError(`Gauge "${input.id}" requires max greater than min.`);
|
|
109
|
+
}
|
|
110
|
+
const orientation = ORIENTATIONS.has(input.orientation) ? input.orientation : 'horizontal';
|
|
111
|
+
const mode = LINEAR_MODES.has(input.mode) ? input.mode : 'fill';
|
|
112
|
+
const states = cloneItems(input.states?.length ? input.states : DEFAULT_STATUS_STATES);
|
|
113
|
+
const gauge = {
|
|
114
|
+
id: input.id,
|
|
115
|
+
type,
|
|
116
|
+
label: String(input.label ?? input.id),
|
|
117
|
+
info: String(input.info ?? ''),
|
|
118
|
+
unit: String(input.unit ?? ''),
|
|
119
|
+
min,
|
|
120
|
+
max,
|
|
121
|
+
value: input.value ?? (type === 'status' ? states[0].value : min),
|
|
122
|
+
orientation,
|
|
123
|
+
mode,
|
|
124
|
+
startAngle: finite(Number(input.startAngle), -135),
|
|
125
|
+
endAngle: finite(Number(input.endAngle), 135),
|
|
126
|
+
bands: cloneItems(input.bands),
|
|
127
|
+
markers: cloneItems(input.markers),
|
|
128
|
+
states,
|
|
129
|
+
row: Number.isInteger(input.row) ? input.row : undefined,
|
|
130
|
+
col: Number.isInteger(input.col) ? input.col : undefined,
|
|
131
|
+
rowSpan: Math.max(1, Math.trunc(finite(Number(input.rowSpan), 1))),
|
|
132
|
+
colSpan: Math.max(1, Math.trunc(finite(Number(input.colSpan), 1)))
|
|
133
|
+
};
|
|
134
|
+
gauge.value = coerceValue(gauge, gauge.value);
|
|
135
|
+
return gauge;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Coerce a runtime value without changing the gauge definition. */
|
|
139
|
+
export function coerceValue(gauge, value) {
|
|
140
|
+
if (gauge.type === 'status') {
|
|
141
|
+
return resolveStatus(gauge, value).value;
|
|
142
|
+
}
|
|
143
|
+
const number = Number(value);
|
|
144
|
+
if (!Number.isFinite(number)) {
|
|
145
|
+
return gauge.value ?? gauge.min;
|
|
146
|
+
}
|
|
147
|
+
if (gauge.type === 'compass') {
|
|
148
|
+
const range = gauge.max - gauge.min;
|
|
149
|
+
return (((number - gauge.min) % range) + range) % range + gauge.min;
|
|
150
|
+
}
|
|
151
|
+
return clamp(number, gauge.min, gauge.max);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Add or update a marker while retaining a serializable plain object. */
|
|
155
|
+
export function setGaugeMarker(gauge, id, value, patch = {}) {
|
|
156
|
+
const index = gauge.markers.findIndex((marker) => marker.id === id);
|
|
157
|
+
const marker = { ...(index >= 0 ? gauge.markers[index] : {}), ...patch, id, value };
|
|
158
|
+
if (index >= 0) {
|
|
159
|
+
gauge.markers[index] = marker;
|
|
160
|
+
} else {
|
|
161
|
+
gauge.markers.push(marker);
|
|
162
|
+
}
|
|
163
|
+
return marker;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Strip internal state and undefined values from a gauge. */
|
|
167
|
+
export function serializeGauge(gauge, placement) {
|
|
168
|
+
const result = {
|
|
169
|
+
id: gauge.id,
|
|
170
|
+
type: gauge.type,
|
|
171
|
+
label: gauge.label,
|
|
172
|
+
info: gauge.info,
|
|
173
|
+
unit: gauge.unit,
|
|
174
|
+
min: gauge.min,
|
|
175
|
+
max: gauge.max,
|
|
176
|
+
value: gauge.value,
|
|
177
|
+
row: placement.row,
|
|
178
|
+
col: placement.col,
|
|
179
|
+
rowSpan: placement.rowSpan,
|
|
180
|
+
colSpan: placement.colSpan,
|
|
181
|
+
bands: cloneItems(gauge.bands),
|
|
182
|
+
markers: cloneItems(gauge.markers)
|
|
183
|
+
};
|
|
184
|
+
if (gauge.type === 'linear') {
|
|
185
|
+
result.orientation = gauge.orientation;
|
|
186
|
+
result.mode = gauge.mode;
|
|
187
|
+
}
|
|
188
|
+
if (gauge.type === 'arc') {
|
|
189
|
+
result.startAngle = gauge.startAngle;
|
|
190
|
+
result.endAngle = gauge.endAngle;
|
|
191
|
+
}
|
|
192
|
+
if (gauge.type === 'status') {
|
|
193
|
+
result.states = cloneItems(gauge.states);
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|