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.
@@ -0,0 +1,736 @@
1
+ import { fitText, layoutCell } from '../layout/CellLayout.js';
2
+ import { layoutHeader } from '../layout/PanelLayout.js';
3
+ import { normalizeValue, projectBand, resolveBand, resolveStatus } from '../model/GaugeModel.js';
4
+ import { color, semanticColor } from '../theme.js';
5
+
6
+ const TAU = Math.PI * 2;
7
+ const BAND_THICKNESS = 2;
8
+ const RADIAL_BAND_LABEL_OFFSET = 12;
9
+ const RADIAL_TARGET_LABEL_OFFSET = 36;
10
+ const RADIAL_BAND_LABEL_SPACE = 20;
11
+ const RADIAL_TARGET_LABEL_SPACE = 44;
12
+ const MIN_RADIAL_RADIUS = 12;
13
+ const RADIAL_TRACK_INSET = 8;
14
+ const RADIAL_TICK_GAP = 3;
15
+ const radians = (degrees) => degrees * Math.PI / 180;
16
+ export const TEXT_INSTANCE_SIZE = 16;
17
+
18
+ function tangentTextAngle(angle) {
19
+ return Math.cos(angle) < 0 ? angle + Math.PI : angle;
20
+ }
21
+
22
+ function radialBaseSpace(halfExtent) {
23
+ return Math.min(10, Math.max(5, halfExtent * 0.1));
24
+ }
25
+
26
+ function canReserveRadialTier(halfExtent, baseRadius, space) {
27
+ const candidate = halfExtent - space;
28
+ return candidate >= Math.max(24, baseRadius * 0.58);
29
+ }
30
+
31
+ function labelFits(atlas, value, maxWidth) {
32
+ return maxWidth > 0 && atlas.measure(String(value), 8, 'label') <= maxWidth;
33
+ }
34
+
35
+ function fitOptionalLabel(atlas, value, maxWidth) {
36
+ const fitted = fitText(value, maxWidth, {
37
+ targetSize: 9,
38
+ minSize: 8,
39
+ measure: (text, size, font) => atlas.measure(text, size, font),
40
+ font: 'label'
41
+ });
42
+ return fitted.text && !fitted.truncated ? fitted : null;
43
+ }
44
+
45
+ function compassBandData(gauge, band) {
46
+ const range = gauge.max - gauge.min;
47
+ const segments = projectBand(band, gauge.min, gauge.max, true);
48
+ const lengths = segments.map(([from, to]) => (to - from) / range);
49
+ const total = lengths.reduce((sum, length) => sum + length, 0);
50
+ let remaining = total / 2;
51
+ let midpoint = segments[0][0];
52
+ for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
53
+ const [from, to] = segments[segmentIndex];
54
+ if (remaining <= lengths[segmentIndex]) {
55
+ midpoint = from + remaining * range;
56
+ break;
57
+ }
58
+ remaining -= lengths[segmentIndex];
59
+ }
60
+ return { band, segments, total, midpoint };
61
+ }
62
+
63
+ function shape(output, geometry, rgba, parameters, extra = [0, 0, 0, 0]) {
64
+ output.push(...geometry, ...rgba, ...parameters, ...extra, 0, 0, 0, 0);
65
+ }
66
+
67
+ function arc(output, x, y, radius, thickness, start, end, rgba, gaugeIndex = 0, dynamicMode = 0) {
68
+ shape(output, [x, y, radius + 3, radius + 3], rgba, [1, start, end, radius - thickness],
69
+ [radius, gaugeIndex, dynamicMode, 0]);
70
+ }
71
+
72
+ function box(output, x, y, width, height, rgba, radius = 0, border = 0, gaugeIndex = 0, dynamicMode = 0) {
73
+ shape(output, [x + width / 2, y + height / 2, width / 2, height / 2], rgba,
74
+ [0, radius, border, 0], [0, gaugeIndex, dynamicMode, 0]);
75
+ }
76
+
77
+ function capsule(output, x, y, width, height, rgba, gaugeIndex = 0, dynamicMode = 0) {
78
+ shape(output, [x + width / 2, y + height / 2, width / 2, height / 2], rgba,
79
+ [2, 0, 0, 0], [0, gaugeIndex, dynamicMode, 0]);
80
+ }
81
+
82
+ function dynamicMarker(output, rect, rgba, gaugeIndex, vertical) {
83
+ shape(output, [rect.x + rect.width / 2, rect.y + rect.height / 2, rect.width / 2, rect.height / 2],
84
+ rgba, [3, 1.2, 0, 0], [0, gaugeIndex, vertical ? 5 : 4, 0]);
85
+ }
86
+
87
+ function compassMarker(output, cx, cy, radius, rgba, gaugeIndex) {
88
+ const scale = Math.min(1, Math.max(0.55, radius / 64));
89
+ const extent = radius + 8;
90
+ shape(output, [cx, cy, extent, extent], rgba,
91
+ [6, 2.5 * scale, radius, 7 * scale], [0, gaugeIndex, 7, 0]);
92
+ }
93
+
94
+ function radialStroke(
95
+ output,
96
+ cx,
97
+ cy,
98
+ markerRadius,
99
+ angle,
100
+ halfWidth,
101
+ halfLength,
102
+ rgba,
103
+ gaugeIndex
104
+ ) {
105
+ const extent = markerRadius + halfLength + 2;
106
+ shape(output, [cx, cy, extent, extent], rgba,
107
+ [7, halfWidth, markerRadius, halfLength], [angle, gaugeIndex, 0, 0]);
108
+ }
109
+
110
+ function radialMarker(output, cx, cy, radius, angle, rgba, gaugeIndex) {
111
+ const scale = Math.min(1, Math.max(0.55, radius / 64));
112
+ radialStroke(output, cx, cy, radius + 5 * scale, angle,
113
+ 2 * scale, 8 * scale, rgba, gaugeIndex);
114
+ }
115
+
116
+ function radialTick(output, cx, cy, radius, angle, major, rgba, gaugeIndex) {
117
+ if (radius < 30) {
118
+ return;
119
+ }
120
+ const scale = Math.min(1, Math.max(0.55, radius / 64));
121
+ const halfLength = (major ? 5 : 3.5) * scale;
122
+ const outerRadius = radius - RADIAL_TRACK_INSET - RADIAL_TICK_GAP;
123
+ radialStroke(output, cx, cy, outerRadius - halfLength, angle,
124
+ (major ? 1.15 : 0.8) * scale, halfLength, rgba, gaugeIndex);
125
+ }
126
+
127
+ function textWidth(atlas, value, size, font = 'regular') {
128
+ return atlas.measure(value, size, font);
129
+ }
130
+
131
+ function snapToPhysicalPixel(value, pixelRatio) {
132
+ return Math.round(value * pixelRatio) / pixelRatio;
133
+ }
134
+
135
+ export function addText(
136
+ atlas,
137
+ output,
138
+ value,
139
+ x,
140
+ y,
141
+ size,
142
+ rgba,
143
+ align = 'left',
144
+ font = 'regular',
145
+ verticalPosition = 'top',
146
+ rotation = 0
147
+ ) {
148
+ const string = String(value);
149
+ const style = atlas.style(font, size);
150
+ const pixelRatio = atlas.pixelRatio;
151
+ const scale = size / style.logicalSize / pixelRatio;
152
+ let cursor = x;
153
+ const measured = textWidth(atlas, string, size, font);
154
+ if (align === 'center') {
155
+ cursor -= measured / 2;
156
+ } else if (align === 'right') {
157
+ cursor -= measured;
158
+ }
159
+ const metrics = atlas.metrics(size, font);
160
+ const baseline = snapToPhysicalPixel(
161
+ verticalPosition === 'baseline' ? y : y + metrics.ascent,
162
+ pixelRatio
163
+ );
164
+ const pivotY = verticalPosition === 'baseline'
165
+ ? y + (metrics.descent - metrics.ascent) / 2
166
+ : y + metrics.lineHeight / 2;
167
+ const rotated = rotation !== 0;
168
+ const cos = rotated ? Math.cos(rotation) : 1;
169
+ const sin = rotated ? Math.sin(rotation) : 0;
170
+ const glyphs = style.glyphs;
171
+ const fallback = glyphs?.get('?');
172
+ for (const character of string) {
173
+ const glyph = glyphs?.get(character) ?? fallback ?? atlas.glyph(character, font, size);
174
+ if (character !== ' ') {
175
+ const left = snapToPhysicalPixel(
176
+ cursor + (glyph.offsetX - glyph.bearingLeft) * scale,
177
+ pixelRatio
178
+ );
179
+ const top = snapToPhysicalPixel(baseline - glyph.bearingTop * scale, pixelRatio);
180
+ const right = snapToPhysicalPixel(left + glyph.width * scale, pixelRatio);
181
+ const bottom = snapToPhysicalPixel(top + glyph.height * scale, pixelRatio);
182
+ const centerX = (left + right) / 2;
183
+ const centerY = (top + bottom) / 2;
184
+ const dx = centerX - x;
185
+ const dy = centerY - pivotY;
186
+ const offset = output.length;
187
+ output.length = offset + TEXT_INSTANCE_SIZE;
188
+ output[offset] = rotated ? x + dx * cos - dy * sin : centerX;
189
+ output[offset + 1] = rotated ? pivotY + dx * sin + dy * cos : centerY;
190
+ output[offset + 2] = (right - left) / 2;
191
+ output[offset + 3] = (bottom - top) / 2;
192
+ output[offset + 4] = glyph.u0;
193
+ output[offset + 5] = glyph.v0;
194
+ output[offset + 6] = glyph.u1;
195
+ output[offset + 7] = glyph.v1;
196
+ output[offset + 8] = rgba[0];
197
+ output[offset + 9] = rgba[1];
198
+ output[offset + 10] = rgba[2];
199
+ output[offset + 11] = rgba[3];
200
+ output[offset + 12] = cos;
201
+ output[offset + 13] = sin;
202
+ output[offset + 14] = 0;
203
+ output[offset + 15] = 0;
204
+ }
205
+ cursor += glyph.advance * scale;
206
+ }
207
+ }
208
+
209
+ function absoluteCellLayout(rect, gauge) {
210
+ const layout = layoutCell({
211
+ width: rect.width,
212
+ height: rect.height,
213
+ gaugeType: gauge.type,
214
+ orientation: gauge.orientation,
215
+ hasInfo: Boolean(gauge.info),
216
+ hasUnit: Boolean(gauge.unit),
217
+ hasMarkers: gauge.markers.length > 0,
218
+ hasMarkerLabels: gauge.markers.some((marker) => Boolean(marker.label)),
219
+ hasBandLabels: gauge.bands.some((band) => Boolean(band.label))
220
+ });
221
+ const absolute = { ...layout };
222
+ for (const name of ['metaRect', 'labelRect', 'infoRect', 'gaugeRect', 'readoutRect']) {
223
+ absolute[name] = {
224
+ ...layout[name],
225
+ x: rect.x + layout[name].x,
226
+ y: rect.y + layout[name].y
227
+ };
228
+ }
229
+ return absolute;
230
+ }
231
+
232
+ function addArcGauge(scene, gauge, rect, theme, index, layout) {
233
+ const cx = rect.x + rect.width / 2;
234
+ const cy = rect.y + rect.height / 2;
235
+ const halfExtent = Math.min(rect.width, rect.height) / 2;
236
+ const start = radians(gauge.startAngle);
237
+ const end = radians(gauge.endAngle);
238
+ const baseSpace = radialBaseSpace(halfExtent);
239
+ const baseRadius = Math.max(MIN_RADIAL_RADIUS, halfExtent - baseSpace);
240
+ const markerRadius = halfExtent - RADIAL_TARGET_LABEL_SPACE;
241
+ const markerLabelWidth = Math.min(markerRadius, rect.width * 0.4);
242
+ const showMarkerLabels = layout.showMarkerLabels
243
+ && canReserveRadialTier(halfExtent, baseRadius, RADIAL_TARGET_LABEL_SPACE)
244
+ && gauge.markers.some((marker) => marker.label
245
+ && labelFits(scene.atlas, marker.label, markerLabelWidth));
246
+ const bandRadius = halfExtent - (showMarkerLabels
247
+ ? RADIAL_TARGET_LABEL_SPACE
248
+ : RADIAL_BAND_LABEL_SPACE);
249
+ const bandLabelFits = (band) => {
250
+ if (!band.label) {
251
+ return false;
252
+ }
253
+ const fromAngle = start + normalizeValue(gauge, band.from) * (end - start);
254
+ const toAngle = start + normalizeValue(gauge, band.to) * (end - start);
255
+ const labelRadius = bandRadius + RADIAL_BAND_LABEL_OFFSET;
256
+ return labelFits(scene.atlas, band.label, Math.max(0, Math.min(
257
+ bandRadius * 1.2,
258
+ Math.abs(toAngle - fromAngle) * labelRadius
259
+ )));
260
+ };
261
+ const showBandLabels = layout.showBandLabels
262
+ && (showMarkerLabels
263
+ || canReserveRadialTier(halfExtent, baseRadius, RADIAL_BAND_LABEL_SPACE))
264
+ && gauge.bands.some(bandLabelFits);
265
+ const labelSpace = showMarkerLabels
266
+ ? RADIAL_TARGET_LABEL_SPACE
267
+ : showBandLabels ? RADIAL_BAND_LABEL_SPACE : baseSpace;
268
+ const radius = Math.max(MIN_RADIAL_RADIUS, halfExtent - labelSpace);
269
+ arc(scene.shapes, cx, cy, radius, 3, start, end, color(theme.grid), index);
270
+ if (layout.showTicks) {
271
+ for (let tick = 0; tick <= 10; tick += 1) {
272
+ const angle = start + tick / 10 * (end - start);
273
+ radialTick(scene.shapes, cx, cy, radius, angle,
274
+ tick === 0 || tick === 5 || tick === 10,
275
+ color(theme.muted, layout.typography.ticks.alpha), index);
276
+ }
277
+ }
278
+ for (const band of gauge.bands) {
279
+ for (const [from, to] of projectBand(band, gauge.min, gauge.max)) {
280
+ const fromAngle = start + normalizeValue(gauge, from) * (end - start);
281
+ const toAngle = start + normalizeValue(gauge, to) * (end - start);
282
+ arc(scene.shapes, cx, cy, radius + 6, BAND_THICKNESS,
283
+ fromAngle,
284
+ toAngle,
285
+ semanticColor(theme, band.kind, 'normal'), index);
286
+ if (showBandLabels && band.label) {
287
+ const angle = (fromAngle + toAngle) / 2;
288
+ const labelRadius = radius + RADIAL_BAND_LABEL_OFFSET;
289
+ const available = Math.max(0, Math.min(
290
+ radius * 1.2,
291
+ Math.abs(toAngle - fromAngle) * labelRadius
292
+ ));
293
+ const fitted = fitOptionalLabel(scene.atlas, band.label, available);
294
+ if (fitted) {
295
+ addText(scene.atlas, scene.text, fitted.text,
296
+ cx + Math.sin(angle) * labelRadius,
297
+ cy - Math.cos(angle) * labelRadius - fitted.size / 2,
298
+ fitted.size, semanticColor(theme, band.kind, 'normal'),
299
+ 'center', 'label', 'top', tangentTextAngle(angle));
300
+ }
301
+ }
302
+ }
303
+ }
304
+ arc(scene.shapes, cx, cy, radius, 8, start, end, color(theme.accent, 0.12), index, 1);
305
+ arc(scene.shapes, cx, cy, radius, 3, start, end, color(theme.accent), index, 1);
306
+ for (const marker of gauge.markers) {
307
+ const angle = start + normalizeValue(gauge, marker.value) * (end - start);
308
+ radialMarker(scene.shapes, cx, cy, radius, angle,
309
+ semanticColor(theme, marker.kind, 'target'), index);
310
+ if (showMarkerLabels && marker.label) {
311
+ const labelRadius = radius + RADIAL_TARGET_LABEL_OFFSET;
312
+ const fitted = fitOptionalLabel(
313
+ scene.atlas,
314
+ marker.label,
315
+ Math.min(radius, rect.width * 0.4)
316
+ );
317
+ if (fitted) {
318
+ addText(scene.atlas, scene.text, fitted.text,
319
+ cx + Math.sin(angle) * labelRadius,
320
+ cy - Math.cos(angle) * labelRadius - fitted.size / 2,
321
+ fitted.size, semanticColor(theme, marker.kind, 'target'),
322
+ 'center', 'label');
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ function addLinearGauge(scene, gauge, rect, theme, index, layout) {
329
+ const vertical = gauge.orientation === 'vertical';
330
+ const length = vertical ? Math.max(1, rect.height - 14) : rect.width * 0.78;
331
+ const thickness = Math.max(5, Math.min(18, vertical ? rect.width * 0.13 : rect.height * 0.18));
332
+ const x = vertical ? rect.x + (rect.width - thickness) / 2 : rect.x + (rect.width - length) / 2;
333
+ const y = vertical ? rect.y + (rect.height - length) / 2 : rect.y + (rect.height - thickness) / 2;
334
+ const width = vertical ? thickness : length;
335
+ const height = vertical ? length : thickness;
336
+ capsule(scene.shapes, x, y, width, height, color(theme.grid), index);
337
+ for (const band of gauge.bands) {
338
+ const from = normalizeValue(gauge, band.from);
339
+ const to = normalizeValue(gauge, band.to);
340
+ const rgba = semanticColor(theme, band.kind, 'normal');
341
+ if (vertical) {
342
+ capsule(scene.shapes, x + width + 5, y + height * (1 - Math.max(from, to)),
343
+ BAND_THICKNESS, height * Math.abs(to - from), rgba, index);
344
+ } else {
345
+ capsule(scene.shapes, x + width * Math.min(from, to), y + height + 5,
346
+ width * Math.abs(to - from), BAND_THICKNESS, rgba, index);
347
+ }
348
+ if (layout.showBandLabels && band.label) {
349
+ const midpoint = (from + to) / 2;
350
+ if (vertical) {
351
+ const available = Math.max(0, x - rect.x - 8);
352
+ const fitted = fitOptionalLabel(scene.atlas, band.label, available);
353
+ if (fitted) {
354
+ addText(scene.atlas, scene.text, fitted.text, x - 7,
355
+ y + height * (1 - midpoint) - fitted.size / 2,
356
+ fitted.size, rgba, 'right', 'label');
357
+ }
358
+ } else {
359
+ const available = width * Math.abs(to - from);
360
+ const fitted = fitOptionalLabel(scene.atlas, band.label, available);
361
+ if (fitted) {
362
+ addText(scene.atlas, scene.text, fitted.text, x + width * midpoint,
363
+ y + height + 9, fitted.size, rgba, 'center', 'label');
364
+ }
365
+ }
366
+ }
367
+ }
368
+ if (gauge.mode === 'fill' || gauge.mode === 'fill-marker') {
369
+ capsule(scene.shapes, x, y, width, height, color(theme.accent), index, vertical ? 3 : 2);
370
+ }
371
+ if (gauge.mode === 'marker' || gauge.mode === 'fill-marker') {
372
+ const markerRect = vertical
373
+ ? { x: x - 4, y, width: width + 8, height }
374
+ : { x, y: y - 4, width, height: height + 8 };
375
+ dynamicMarker(scene.shapes, markerRect, color(theme.accent), index, vertical);
376
+ }
377
+ for (const marker of gauge.markers) {
378
+ const normalized = normalizeValue(gauge, marker.value);
379
+ if (vertical) {
380
+ capsule(scene.shapes, x - 5, y + height * (1 - normalized) - 1, width + 10, 2,
381
+ semanticColor(theme, marker.kind, 'target'), index);
382
+ } else {
383
+ capsule(scene.shapes, x + width * normalized - 1, y - 5, 2, height + 10,
384
+ semanticColor(theme, marker.kind, 'target'), index);
385
+ }
386
+ }
387
+ }
388
+
389
+ function addCompass(scene, gauge, rect, theme, index, layout) {
390
+ const cx = rect.x + rect.width / 2;
391
+ const cy = rect.y + rect.height / 2;
392
+ const halfExtent = Math.min(rect.width, rect.height) / 2;
393
+ const baseSpace = radialBaseSpace(halfExtent);
394
+ const baseRadius = Math.max(MIN_RADIAL_RADIUS, halfExtent - baseSpace);
395
+ const markerRadius = halfExtent - RADIAL_TARGET_LABEL_SPACE;
396
+ const markerLabelWidth = Math.min(markerRadius, rect.width * 0.4);
397
+ const showMarkerLabels = layout.showMarkerLabels
398
+ && canReserveRadialTier(halfExtent, baseRadius, RADIAL_TARGET_LABEL_SPACE)
399
+ && gauge.markers.some((marker) => marker.label
400
+ && labelFits(scene.atlas, marker.label, markerLabelWidth));
401
+ const projectedBands = gauge.bands.map((band) => compassBandData(gauge, band));
402
+ const bandRadius = halfExtent - (showMarkerLabels
403
+ ? RADIAL_TARGET_LABEL_SPACE
404
+ : RADIAL_BAND_LABEL_SPACE);
405
+ const showBandLabels = layout.showBandLabels
406
+ && (showMarkerLabels
407
+ || canReserveRadialTier(halfExtent, baseRadius, RADIAL_BAND_LABEL_SPACE))
408
+ && projectedBands.some(({ band, total }) => band.label
409
+ && labelFits(scene.atlas, band.label, Math.max(0, Math.min(
410
+ bandRadius * 1.2,
411
+ total * TAU * (bandRadius + RADIAL_BAND_LABEL_OFFSET)
412
+ ))));
413
+ const labelSpace = showMarkerLabels
414
+ ? RADIAL_TARGET_LABEL_SPACE
415
+ : showBandLabels ? RADIAL_BAND_LABEL_SPACE : baseSpace;
416
+ const radius = Math.max(MIN_RADIAL_RADIUS, halfExtent - labelSpace);
417
+ arc(scene.shapes, cx, cy, radius, 2, 0, TAU, color(theme.grid), index);
418
+ for (const { band, segments, total, midpoint } of projectedBands) {
419
+ for (const [from, to] of segments) {
420
+ arc(scene.shapes, cx, cy, radius + 5, BAND_THICKNESS,
421
+ normalizeValue(gauge, from) * TAU,
422
+ normalizeValue(gauge, to === gauge.max ? gauge.min : to) * TAU
423
+ + (to === gauge.max ? TAU : 0),
424
+ semanticColor(theme, band.kind, 'normal'), index);
425
+ }
426
+ if (showBandLabels && band.label) {
427
+ const angle = normalizeValue(gauge, midpoint) * TAU;
428
+ const labelRadius = radius + RADIAL_BAND_LABEL_OFFSET;
429
+ const available = Math.max(0, Math.min(radius * 1.2, total * TAU * labelRadius));
430
+ const fitted = fitOptionalLabel(scene.atlas, band.label, available);
431
+ if (fitted) {
432
+ addText(scene.atlas, scene.text, fitted.text,
433
+ cx + Math.sin(angle) * labelRadius,
434
+ cy - Math.cos(angle) * labelRadius - fitted.size / 2,
435
+ fitted.size, semanticColor(theme, band.kind, 'normal'),
436
+ 'center', 'label', 'top', tangentTextAngle(angle));
437
+ }
438
+ }
439
+ }
440
+ if (layout.showTicks) {
441
+ for (let tick = 0; tick < 12; tick += 1) {
442
+ if (tick % 3 === 0) {
443
+ continue;
444
+ }
445
+ const angle = tick / 12 * TAU;
446
+ radialTick(scene.shapes, cx, cy, radius, angle, false,
447
+ color(theme.muted, layout.typography.ticks.alpha), index);
448
+ }
449
+ }
450
+ for (const marker of gauge.markers) {
451
+ const angle = normalizeValue(gauge, marker.value) * TAU;
452
+ radialMarker(scene.shapes, cx, cy, radius, angle,
453
+ semanticColor(theme, marker.kind, 'target'), index);
454
+ if (showMarkerLabels && marker.label) {
455
+ const labelRadius = radius + RADIAL_TARGET_LABEL_OFFSET;
456
+ const fitted = fitOptionalLabel(
457
+ scene.atlas,
458
+ marker.label,
459
+ Math.min(radius, rect.width * 0.4)
460
+ );
461
+ if (fitted) {
462
+ addText(scene.atlas, scene.text, fitted.text,
463
+ cx + Math.sin(angle) * labelRadius,
464
+ cy - Math.cos(angle) * labelRadius - fitted.size / 2,
465
+ fitted.size, semanticColor(theme, marker.kind, 'target'),
466
+ 'center', 'label');
467
+ }
468
+ }
469
+ }
470
+ compassMarker(scene.shapes, cx, cy, radius, color(theme.accent), index);
471
+ if (layout.showCompassLabels) {
472
+ const labelRadius = Math.max(4, radius - 12);
473
+ for (const [label, angle] of [['N', 0], ['E', Math.PI / 2], ['S', Math.PI], ['W', Math.PI * 1.5]]) {
474
+ addText(scene.atlas, scene.text, label, cx + Math.sin(angle) * labelRadius,
475
+ cy - Math.cos(angle) * labelRadius - layout.typography.unit.size / 2,
476
+ layout.typography.unit.size, color(theme.text, 0.55),
477
+ 'center', layout.typography.unit.font);
478
+ }
479
+ }
480
+ }
481
+
482
+ function addStatus(scene, rect, theme, index) {
483
+ const radius = Math.max(5, Math.min(rect.width, rect.height) * 0.14);
484
+ const cx = rect.x + rect.width / 2;
485
+ const cy = rect.y + rect.height / 2;
486
+ shape(scene.shapes, [cx, cy, radius * 2, radius * 2], color(theme.inactive, 0.25),
487
+ [5, radius * 1.6, 0, 0], [0, index, 6, 0]);
488
+ shape(scene.shapes, [cx, cy, radius, radius], color(theme.inactive),
489
+ [5, radius, 0, 0], [0, index, 6, 0]);
490
+ }
491
+
492
+ export function buildStaticScene(atlas, gauges, rectangles, header, theme, panelLayout) {
493
+ const scene = { shapes: [], text: [], atlas, cellLayouts: new Map(), iconRect: null };
494
+ const { headerRect } = panelLayout;
495
+ if (header && headerRect.height > 0) {
496
+ box(scene.shapes, headerRect.x, headerRect.y, headerRect.width, headerRect.height,
497
+ color(theme.surface), 8);
498
+ const headerContent = layoutHeader(headerRect, { hasIcon: Boolean(header.icon) });
499
+ scene.iconRect = headerContent.iconRect;
500
+ const badgeVisible = Boolean(header.badge) && headerRect.width > 420;
501
+ const textWidthAvailable = badgeVisible
502
+ ? headerContent.badgeRect.x - headerContent.title.x - 12
503
+ : headerRect.x + headerRect.width - headerContent.title.x - 18;
504
+ if (header.title) {
505
+ const fitted = fitText(header.title, textWidthAvailable, {
506
+ targetSize: headerContent.title.size,
507
+ minSize: 11,
508
+ measure: (text, size, font) => atlas.measure(text, size, font),
509
+ font: 'header'
510
+ });
511
+ addText(atlas, scene.text, fitted.text, headerContent.title.x, headerContent.title.y,
512
+ fitted.size, color(theme.text, 0.95), 'left', 'header');
513
+ }
514
+ if (header.subtitle && headerRect.width > 280) {
515
+ const fitted = fitText(header.subtitle, textWidthAvailable, {
516
+ targetSize: headerContent.subtitle.size,
517
+ minSize: 9,
518
+ measure: (text, size, font) => atlas.measure(text, size, font),
519
+ font: 'regular'
520
+ });
521
+ addText(atlas, scene.text, fitted.text, headerContent.subtitle.x, headerContent.subtitle.y,
522
+ fitted.size, color(theme.text, 0.70), 'left', 'regular');
523
+ }
524
+ if (badgeVisible) {
525
+ const fitted = fitText(header.badge, headerContent.badgeRect.width - 16, {
526
+ targetSize: 10,
527
+ minSize: 9,
528
+ measure: (text, size, font) => atlas.measure(text, size, font),
529
+ font: 'label'
530
+ });
531
+ const badgeWidth = fitted.width + 16;
532
+ const badgeRect = {
533
+ ...headerContent.badgeRect,
534
+ x: headerRect.x + headerRect.width - 18 - badgeWidth,
535
+ width: badgeWidth
536
+ };
537
+ box(scene.shapes, badgeRect.x, badgeRect.y, badgeRect.width, badgeRect.height,
538
+ color(theme.accent, 0.09), 6, 1);
539
+ addText(atlas, scene.text, fitted.text, badgeRect.x + badgeRect.width / 2,
540
+ badgeRect.y + 5, fitted.size, color(theme.accent), 'center', 'label');
541
+ }
542
+ }
543
+ const measure = (text, size, font) => atlas.measure(text, size, font);
544
+ gauges.forEach((gauge, index) => {
545
+ const rect = rectangles.get(gauge.id);
546
+ if (!rect) {
547
+ return;
548
+ }
549
+ const layout = absoluteCellLayout(rect, gauge);
550
+ scene.cellLayouts.set(gauge.id, layout);
551
+ box(scene.shapes, rect.x, rect.y, rect.width, rect.height, color(theme.surface), 7);
552
+ box(scene.shapes, rect.x, rect.y, rect.width, rect.height, color(theme.grid, 0.7), 7, 1);
553
+
554
+ const fittedLabel = fitText(gauge.label, layout.labelRect.width, {
555
+ targetSize: layout.typography.label.size,
556
+ minSize: layout.typography.label.minSize,
557
+ measure,
558
+ font: layout.typography.label.font
559
+ });
560
+ addText(atlas, scene.text, fittedLabel.text, layout.labelRect.x, layout.labelRect.y,
561
+ fittedLabel.size, color(theme.text, layout.typography.label.alpha),
562
+ 'left', layout.typography.label.font);
563
+ if (layout.showInfo) {
564
+ const fittedInfo = fitText(gauge.info, layout.infoRect.width, {
565
+ targetSize: layout.typography.info.size,
566
+ minSize: layout.typography.info.minSize,
567
+ measure,
568
+ font: layout.typography.info.font
569
+ });
570
+ addText(atlas, scene.text, fittedInfo.text, layout.infoRect.x, layout.infoRect.y,
571
+ fittedInfo.size, color(theme.text, layout.typography.info.alpha),
572
+ 'left', layout.typography.info.font);
573
+ }
574
+
575
+ if (gauge.type === 'arc') {
576
+ addArcGauge(scene, gauge, layout.gaugeRect, theme, index, layout);
577
+ } else if (gauge.type === 'linear') {
578
+ addLinearGauge(scene, gauge, layout.gaugeRect, theme, index, layout);
579
+ } else if (gauge.type === 'compass') {
580
+ addCompass(scene, gauge, layout.gaugeRect, theme, index, layout);
581
+ } else {
582
+ addStatus(scene, layout.gaugeRect, theme, index);
583
+ }
584
+ });
585
+ return scene;
586
+ }
587
+
588
+ export function formatGaugeValue(gauge) {
589
+ if (gauge.type === 'status') {
590
+ return resolveStatus(gauge, gauge.value).label;
591
+ }
592
+ const value = Number(gauge.value);
593
+ if (gauge.type === 'compass') {
594
+ return Math.round(value).toString().padStart(3, '0');
595
+ }
596
+ return Math.abs(value) >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
597
+ }
598
+
599
+ /** A compact key for deciding whether dynamic glyph geometry or color actually changed. */
600
+ export function dynamicTextKey(gauges) {
601
+ let key = '';
602
+ for (const gauge of gauges) {
603
+ const formatted = formatGaugeValue(gauge);
604
+ if (gauge.type === 'status') {
605
+ const status = resolveStatus(gauge, gauge.value);
606
+ key += `${formatted.length}:${formatted}@status:${status.kind ?? 'normal'}|`;
607
+ } else {
608
+ const activeBand = resolveBand(gauge);
609
+ const bandKind = activeBand ? activeBand.kind ?? 'normal' : 'text';
610
+ key += `${formatted.length}:${formatted}@${bandKind}|`;
611
+ }
612
+ }
613
+ return key;
614
+ }
615
+
616
+ /** Write the small per-gauge value/color records without allocating an intermediate array. */
617
+ export function buildDynamicValues(gauges, theme, output = []) {
618
+ const length = Math.max(8, gauges.length * 8);
619
+ if (Array.isArray(output)) {
620
+ output.length = length;
621
+ } else if (output.length < length) {
622
+ throw new RangeError('Dynamic value output is smaller than required.');
623
+ }
624
+ let offset = 0;
625
+ const accent = color(theme.accent);
626
+ for (const gauge of gauges) {
627
+ const normalized = gauge.type === 'status' ? 0 : normalizeValue(gauge, gauge.value);
628
+ const status = gauge.type === 'status' ? resolveStatus(gauge, gauge.value) : null;
629
+ const rgba = status
630
+ ? semanticColor(theme, status.kind, status.kind === 'inactive' ? 'inactive' : 'normal')
631
+ : accent;
632
+ output[offset] = normalized;
633
+ output[offset + 1] = Number(gauge.value) || 0;
634
+ output[offset + 2] = 0;
635
+ output[offset + 3] = 0;
636
+ output[offset + 4] = rgba[0];
637
+ output[offset + 5] = rgba[1];
638
+ output[offset + 6] = rgba[2];
639
+ output[offset + 7] = rgba[3];
640
+ offset += 8;
641
+ }
642
+ if (gauges.length === 0) {
643
+ output[0] = 0;
644
+ output[1] = 0;
645
+ output[2] = 0;
646
+ output[3] = 0;
647
+ output[4] = 1;
648
+ output[5] = 1;
649
+ output[6] = 1;
650
+ output[7] = 1;
651
+ }
652
+ return output;
653
+ }
654
+
655
+ /** Rebuild glyph instances only when the formatted readout or semantic color changes. */
656
+ export function buildDynamicText(atlas, gauges, cellLayouts, theme, output = []) {
657
+ output.length = 0;
658
+ const measure = (value, size, font) => atlas.measure(value, size, font);
659
+ gauges.forEach((gauge) => {
660
+ const status = gauge.type === 'status' ? resolveStatus(gauge, gauge.value) : null;
661
+ const activeBand = gauge.type === 'status' ? null : resolveBand(gauge);
662
+ const valueColor = activeBand
663
+ ? semanticColor(theme, activeBand.kind, 'normal')
664
+ : color(theme.text);
665
+ const layout = cellLayouts.get(gauge.id);
666
+ if (!layout) {
667
+ return;
668
+ }
669
+ const formatted = formatGaugeValue(gauge);
670
+ const stacked = layout.readoutMode.endsWith('-stacked');
671
+ const unit = layout.showUnit && gauge.unit
672
+ ? fitText(gauge.unit, stacked ? layout.readoutRect.width : layout.readoutRect.width * 0.34, {
673
+ targetSize: layout.typography.unit.size,
674
+ minSize: layout.typography.unit.minSize,
675
+ measure,
676
+ font: layout.typography.unit.font
677
+ })
678
+ : null;
679
+ const unitWidth = !stacked && unit ? unit.width + 6 : 0;
680
+ const fitted = fitText(formatted, layout.readoutRect.width - unitWidth, {
681
+ targetSize: layout.typography.value.size,
682
+ minSize: layout.typography.value.minSize,
683
+ measure,
684
+ font: layout.typography.value.font
685
+ });
686
+ const readoutColor = status
687
+ ? semanticColor(theme, status.kind, status.kind === 'inactive' ? 'inactive' : 'normal')
688
+ : valueColor;
689
+ const valueWidth = textWidth(atlas, fitted.text, fitted.size, layout.typography.value.font);
690
+ const valueMetrics = atlas.metrics(fitted.size, layout.typography.value.font);
691
+ if (stacked) {
692
+ const unitMetrics = unit
693
+ ? atlas.metrics(unit.size, layout.typography.unit.font)
694
+ : { ascent: 0, descent: 0, lineHeight: 0 };
695
+ const gap = unit ? 4 : 0;
696
+ const totalHeight = valueMetrics.lineHeight + gap + unitMetrics.lineHeight;
697
+ const top = layout.readoutRect.y + (layout.readoutRect.height - totalHeight) / 2;
698
+ addText(atlas, output, fitted.text,
699
+ layout.readoutRect.x + layout.readoutRect.width / 2,
700
+ top + valueMetrics.ascent, fitted.size, readoutColor,
701
+ 'center', layout.typography.value.font, 'baseline');
702
+ if (unit) {
703
+ addText(atlas, output, unit.text,
704
+ layout.readoutRect.x + layout.readoutRect.width / 2,
705
+ top + valueMetrics.lineHeight + gap + unitMetrics.ascent,
706
+ unit.size, color(theme.text, layout.typography.unit.alpha),
707
+ 'center', layout.typography.unit.font, 'baseline');
708
+ }
709
+ } else {
710
+ const groupX = layout.readoutRect.x
711
+ + (layout.readoutRect.width - valueWidth - unitWidth) / 2;
712
+ const unitMetrics = unit
713
+ ? atlas.metrics(unit.size, layout.typography.unit.font)
714
+ : { ascent: 0, descent: 0 };
715
+ const ascent = Math.max(valueMetrics.ascent, unitMetrics.ascent);
716
+ const descent = Math.max(valueMetrics.descent, unitMetrics.descent);
717
+ const baseline = layout.readoutRect.y
718
+ + (layout.readoutRect.height - ascent - descent) / 2 + ascent;
719
+ addText(atlas, output, fitted.text, groupX, baseline, fitted.size, readoutColor,
720
+ 'left', layout.typography.value.font, 'baseline');
721
+ if (unit) {
722
+ addText(atlas, output, unit.text, groupX + valueWidth + 6, baseline,
723
+ unit.size, color(theme.text, layout.typography.unit.alpha),
724
+ 'left', layout.typography.unit.font, 'baseline');
725
+ }
726
+ }
727
+ });
728
+ return output;
729
+ }
730
+
731
+ export function buildDynamicData(atlas, gauges, cellLayouts, theme) {
732
+ return {
733
+ values: buildDynamicValues(gauges, theme),
734
+ text: buildDynamicText(atlas, gauges, cellLayouts, theme)
735
+ };
736
+ }