@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.111 → 2.0.0-next.113

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.
Files changed (85) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +185 -29
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +180 -21
  4. package/dist/building-blocks/readout-block/readout-block.d.ts +29 -5
  5. package/dist/building-blocks/readout-block/readout-block.d.ts.map +1 -1
  6. package/dist/building-blocks/readout-block/readout-block.js +21 -7
  7. package/dist/building-blocks/readout-block/readout-block.js.map +1 -1
  8. package/dist/navigation-instruments/compass/compass.d.ts +1 -1
  9. package/dist/navigation-instruments/compass/compass.d.ts.map +1 -1
  10. package/dist/navigation-instruments/compass/compass.js +1 -1
  11. package/dist/navigation-instruments/compass/compass.js.map +1 -1
  12. package/dist/navigation-instruments/current/current.d.ts +25 -0
  13. package/dist/navigation-instruments/current/current.d.ts.map +1 -1
  14. package/dist/navigation-instruments/current/current.js +25 -4
  15. package/dist/navigation-instruments/current/current.js.map +1 -1
  16. package/dist/navigation-instruments/heading/heading.js +1 -1
  17. package/dist/navigation-instruments/heading/heading.js.map +1 -1
  18. package/dist/navigation-instruments/pitch-roll-heave/pitch-roll-heave.d.ts +6 -0
  19. package/dist/navigation-instruments/pitch-roll-heave/pitch-roll-heave.d.ts.map +1 -1
  20. package/dist/navigation-instruments/pitch-roll-heave/pitch-roll-heave.js +8 -1
  21. package/dist/navigation-instruments/pitch-roll-heave/pitch-roll-heave.js.map +1 -1
  22. package/dist/navigation-instruments/rate-of-turn/rate-of-turn.d.ts +5 -2
  23. package/dist/navigation-instruments/rate-of-turn/rate-of-turn.d.ts.map +1 -1
  24. package/dist/navigation-instruments/rate-of-turn/rate-of-turn.js.map +1 -1
  25. package/dist/navigation-instruments/readout/readout-formatters.d.ts +31 -0
  26. package/dist/navigation-instruments/readout/readout-formatters.d.ts.map +1 -1
  27. package/dist/navigation-instruments/readout/readout-formatters.js +55 -1
  28. package/dist/navigation-instruments/readout/readout-formatters.js.map +1 -1
  29. package/dist/navigation-instruments/readout/readout.d.ts +26 -1
  30. package/dist/navigation-instruments/readout/readout.d.ts.map +1 -1
  31. package/dist/navigation-instruments/readout/readout.js +16 -3
  32. package/dist/navigation-instruments/readout/readout.js.map +1 -1
  33. package/dist/navigation-instruments/readout-list/readout-list.d.ts +6 -1
  34. package/dist/navigation-instruments/readout-list/readout-list.d.ts.map +1 -1
  35. package/dist/navigation-instruments/readout-list/readout-list.js +21 -4
  36. package/dist/navigation-instruments/readout-list/readout-list.js.map +1 -1
  37. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts +26 -1
  38. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts.map +1 -1
  39. package/dist/navigation-instruments/readout-list-item/readout-list-item.js +15 -3
  40. package/dist/navigation-instruments/readout-list-item/readout-list-item.js.map +1 -1
  41. package/dist/navigation-instruments/top-view-propulsion/top-view-propulsion.d.ts +6 -0
  42. package/dist/navigation-instruments/top-view-propulsion/top-view-propulsion.d.ts.map +1 -1
  43. package/dist/navigation-instruments/top-view-propulsion/top-view-propulsion.js +19 -4
  44. package/dist/navigation-instruments/top-view-propulsion/top-view-propulsion.js.map +1 -1
  45. package/dist/navigation-instruments/watch/environment.d.ts +8 -0
  46. package/dist/navigation-instruments/watch/environment.d.ts.map +1 -1
  47. package/dist/navigation-instruments/watch/environment.js +3 -0
  48. package/dist/navigation-instruments/watch/environment.js.map +1 -1
  49. package/dist/navigation-instruments/watch/force-pattern.d.ts +20 -2
  50. package/dist/navigation-instruments/watch/force-pattern.d.ts.map +1 -1
  51. package/dist/navigation-instruments/watch/force-pattern.js +12 -5
  52. package/dist/navigation-instruments/watch/force-pattern.js.map +1 -1
  53. package/dist/navigation-instruments/watch/watch.d.ts.map +1 -1
  54. package/dist/navigation-instruments/watch/watch.js +6 -4
  55. package/dist/navigation-instruments/watch/watch.js.map +1 -1
  56. package/dist/navigation-instruments/wind/wind.d.ts +5 -2
  57. package/dist/navigation-instruments/wind/wind.d.ts.map +1 -1
  58. package/dist/navigation-instruments/wind/wind.js +5 -1
  59. package/dist/navigation-instruments/wind/wind.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/building-blocks/readout-block/readout-block.stories.ts +112 -3
  62. package/src/building-blocks/readout-block/readout-block.ts +64 -10
  63. package/src/navigation-instruments/compass/compass.ts +2 -2
  64. package/src/navigation-instruments/current/current.stories.ts +33 -0
  65. package/src/navigation-instruments/current/current.ts +37 -3
  66. package/src/navigation-instruments/heading/heading.stories.ts +6 -4
  67. package/src/navigation-instruments/heading/heading.ts +1 -1
  68. package/src/navigation-instruments/pitch-roll-heave/pitch-roll-heave.ts +8 -1
  69. package/src/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts +3 -3
  70. package/src/navigation-instruments/rate-of-turn/rate-of-turn.ts +5 -2
  71. package/src/navigation-instruments/readout/readout-formatters.spec.ts +229 -0
  72. package/src/navigation-instruments/readout/readout-formatters.ts +105 -0
  73. package/src/navigation-instruments/readout/readout.stories.ts +167 -3
  74. package/src/navigation-instruments/readout/readout.ts +52 -4
  75. package/src/navigation-instruments/readout-list/readout-list.stories.ts +122 -1
  76. package/src/navigation-instruments/readout-list/readout-list.ts +54 -5
  77. package/src/navigation-instruments/readout-list-item/readout-list-item.stories.ts +124 -4
  78. package/src/navigation-instruments/readout-list-item/readout-list-item.ts +53 -4
  79. package/src/navigation-instruments/top-view-propulsion/top-view-propulsion.ts +20 -2
  80. package/src/navigation-instruments/watch/environment.ts +10 -0
  81. package/src/navigation-instruments/watch/force-pattern.spec.ts +42 -0
  82. package/src/navigation-instruments/watch/force-pattern.ts +44 -7
  83. package/src/navigation-instruments/watch/watch.ts +20 -5
  84. package/src/navigation-instruments/wind/wind.spec.ts +3 -1
  85. package/src/navigation-instruments/wind/wind.ts +12 -3
@@ -7,6 +7,7 @@ import {
7
7
  ReadoutListItemPriority,
8
8
  ReadoutListItemDataQuality,
9
9
  type ReadoutValueOptions,
10
+ ReadoutValueType,
10
11
  } from '../readout-list-item/readout-list-item.js';
11
12
  import '../readout-list-item/readout-list-item.js';
12
13
  import type {AlertFrameConfig} from '../../components/alert-frame/alert-frame.js';
@@ -22,7 +23,8 @@ type ListArgs = {
22
23
 
23
24
  type Row = {
24
25
  label: string;
25
- value: number | null;
26
+ value: number | string | null;
27
+ valueType?: ReadoutValueType;
26
28
  unit: string;
27
29
  size?: ReadoutListItemSize;
28
30
  hasDegree?: boolean;
@@ -55,6 +57,7 @@ function renderRow(row: Row) {
55
57
  .label=${row.label}
56
58
  .unit=${row.unit}
57
59
  .value=${row.value}
60
+ .valueType=${row.valueType ?? ReadoutValueType.number}
58
61
  .size=${row.size ?? ReadoutListItemSize.small}
59
62
  .hasDegree=${row.hasDegree ?? false}
60
63
  .fractionDigits=${row.fractionDigits ?? 0}
@@ -112,6 +115,49 @@ export const Default: Story = {
112
115
  render: (args) => renderList(MIXED_ROWS, args.showDebugOverlay),
113
116
  };
114
117
 
118
+ /**
119
+ * **Mixed text and numeric rows.** Rows with `valueType="text"` render their
120
+ * value verbatim and are **excluded from the auto-computed numeric reserver in
121
+ * both directions**:
122
+ *
123
+ * - they do not *contribute* to it, so a long string like "Thermo On" cannot
124
+ * inflate the shared value column for every numeric row;
125
+ * - they do not *receive* it either, so a short string like "Auto" is not padded
126
+ * out to a digit width. Each text row's value block sizes to its own content.
127
+ *
128
+ * A text row's setpoint / advice blocks stay numeric and are reserved normally.
129
+ */
130
+ const TEXT_ROWS: Row[] = [
131
+ {
132
+ label: 'Operating mode',
133
+ value: 'Thermo On',
134
+ valueType: ReadoutValueType.text,
135
+ unit: '',
136
+ },
137
+ {
138
+ label: 'Status',
139
+ value: 'Normal',
140
+ valueType: ReadoutValueType.text,
141
+ unit: '',
142
+ },
143
+ // Deliberately shorter than the numeric reserve ("0000.0"), so this row
144
+ // demonstrates that a text value hugs its content instead of being padded
145
+ // out to the digit column's width.
146
+ {
147
+ label: 'Mode',
148
+ value: 'Auto',
149
+ valueType: ReadoutValueType.text,
150
+ unit: '',
151
+ },
152
+ {label: 'Temperature', value: 45, unit: 'C', hasDegree: true},
153
+ {label: 'Pressure', value: 1013, unit: 'Pa', fractionDigits: 1},
154
+ {label: 'Speed', value: 18.4, unit: 'kn', fractionDigits: 1},
155
+ ];
156
+
157
+ export const TextValues: Story = {
158
+ render: (args) => renderList(TEXT_ROWS, args.showDebugOverlay),
159
+ };
160
+
115
161
  const DEGREE_ROWS: Row[] = [
116
162
  {label: 'Heading', value: 287, unit: 'T', hasDegree: true},
117
163
  {label: 'COG', value: 92, unit: 'T', hasDegree: true},
@@ -182,6 +228,81 @@ export const WithSetpoints: Story = {
182
228
  ),
183
229
  };
184
230
 
231
+ /**
232
+ * Attribute-driven regression test for text rows. Uses plain HTML attributes
233
+ * (not property bindings) so it covers the path where `value` arrives as a raw
234
+ * string: `valuetype="text"` must render verbatim and stay out of the shared
235
+ * numeric reserver, while a numeric `value="1013"` under the default value type
236
+ * must still resolve to a number and drive the reserver.
237
+ *
238
+ * Note the attribute is `valuetype`, all lowercase — Lit lowercases a property
239
+ * name to derive its attribute rather than kebab-casing it.
240
+ */
241
+ export const TestTextRowAttributes: Story = {
242
+ render: () => html`
243
+ <div
244
+ data-obc-theme="day"
245
+ style="background: var(--container-background-color); padding: 16px; width: 360px; box-sizing: border-box;"
246
+ >
247
+ <obc-readout-list>
248
+ <obc-readout-list-item
249
+ id="text-row"
250
+ label="Operating mode"
251
+ value="Thermo On"
252
+ valuetype="text"
253
+ ></obc-readout-list-item>
254
+ <obc-readout-list-item
255
+ id="short-text-row"
256
+ label="Mode"
257
+ value="Auto"
258
+ valuetype="text"
259
+ maxdigits="8"
260
+ fractiondigits="3"
261
+ ></obc-readout-list-item>
262
+ <obc-readout-list-item
263
+ id="numeric-row"
264
+ label="Pressure"
265
+ unit="Pa"
266
+ value="1013"
267
+ ></obc-readout-list-item>
268
+ </obc-readout-list>
269
+ </div>
270
+ `,
271
+ play: async ({canvasElement}) => {
272
+ await new Promise((resolve) => requestAnimationFrame(resolve));
273
+ await new Promise((resolve) => requestAnimationFrame(resolve));
274
+
275
+ const textRow = canvasElement.querySelector('#text-row') as HTMLElement & {
276
+ valueType: ReadoutValueType;
277
+ valueOptions?: ReadoutValueOptions;
278
+ };
279
+ const shortTextRow = canvasElement.querySelector(
280
+ '#short-text-row'
281
+ ) as HTMLElement & {valueOptions?: ReadoutValueOptions};
282
+ const numericRow = canvasElement.querySelector(
283
+ '#numeric-row'
284
+ ) as HTMLElement & {valueOptions?: ReadoutValueOptions};
285
+
286
+ // The attribute reached the property as the enum value.
287
+ await expect(textRow.valueType).toBe(ReadoutValueType.text);
288
+ // Text renders verbatim. The value lives in the nested obc-readout-block's
289
+ // shadow root, so reach through it rather than the row's own.
290
+ const block = textRow.shadowRoot?.querySelector('obc-readout-block');
291
+ await expect(block?.shadowRoot?.textContent).toContain('Thermo On');
292
+
293
+ // The reserve is driven by the 4-digit numeric row only. Neither the long
294
+ // text ("Thermo On") nor the text row's own maxdigits=8 / fractiondigits=3
295
+ // inflate it — a text block ignores those, so counting them would pad every
296
+ // numeric row for nothing.
297
+ await expect(numericRow.valueOptions?.spaceReserver).toBe('0000');
298
+
299
+ // Text rows do not RECEIVE the numeric reserve either: it is a width in
300
+ // digits, so it would pad "Auto" out to the numeric column's width.
301
+ await expect(textRow.valueOptions?.spaceReserver).toBeUndefined();
302
+ await expect(shortTextRow.valueOptions?.spaceReserver).toBeUndefined();
303
+ },
304
+ };
305
+
185
306
  export const TestDynamicRow: Story = {
186
307
  render: (args) => renderList(MIXED_ROWS, args.showDebugOverlay),
187
308
  play: async ({canvasElement}) => {
@@ -4,16 +4,31 @@ import componentStyle from './readout-list.css?inline';
4
4
  import {customElement} from '../../decorator.js';
5
5
  import '../readout-list-item/readout-list-item.js';
6
6
  import {ObcReadoutListItem} from '../readout-list-item/readout-list-item.js';
7
+ import {
8
+ resolveReadoutNumericValue,
9
+ ReadoutValueType,
10
+ } from '../readout/readout-formatters.js';
7
11
 
8
12
  const ITEM_TAG = 'obc-readout-list-item';
9
13
 
14
+ // Lit lowercases a property name to derive its attribute (it does not
15
+ // kebab-case), so multi-word properties are observed as one lowercase word —
16
+ // `valueType` becomes `valuetype`.
17
+ //
18
+ // The kebab-cased entries below are therefore INERT: the real attributes are
19
+ // `maxdigits`, `fractiondigits`, `hasdegree`, `hassetpoint` and `hasadvice`, so
20
+ // changing any of them does not currently re-trigger alignment. They predate
21
+ // this list and are left as-is rather than silently activating five code paths
22
+ // that have never run; see the follow-up issue before adding more entries.
10
23
  /** Child attributes whose change should re-trigger alignment (HTML-attribute usage). */
11
24
  const OBSERVED_ATTRIBUTES = [
12
25
  'unit',
13
26
  'src',
14
27
  'value',
28
+ 'valuetype',
15
29
  'setpoint',
16
30
  'advice',
31
+ // ⚠ inert — see the note above; the real names have no hyphens.
17
32
  'max-digits',
18
33
  'fraction-digits',
19
34
  'has-degree',
@@ -21,6 +36,11 @@ const OBSERVED_ATTRIBUTES = [
21
36
  'has-advice',
22
37
  ];
23
38
 
39
+ /** Whether a row renders its value as verbatim text rather than a number. */
40
+ function isTextValueRow(item: ObcReadoutListItem): boolean {
41
+ return item.valueType === ReadoutValueType.text;
42
+ }
43
+
24
44
  /** Integer-digit count of a numeric value (sign and fraction excluded). */
25
45
  function integerDigitCount(value: number | null | undefined): number {
26
46
  if (value === null || value === undefined || Number.isNaN(value)) {
@@ -45,7 +65,12 @@ function integerDigitCount(value: number | null | undefined): number {
45
65
  * - **Value / setpoint / advice:** the widest numeric width (max integer digits +
46
66
  * max fraction digits across rows, derived from each row's `maxDigits` /
47
67
  * `fractionDigits` / current values) is reserved on every row's numeric blocks.
48
- * Reserving off digit counts keeps it stable as live values update.
68
+ * Reserving off digit counts keeps it stable as live values update. A row with
69
+ * `valueType="text"` is excluded in both directions — it neither contributes
70
+ * to the reserve (so a long text value cannot inflate the numeric column) nor
71
+ * receives it (so short text is not padded to a digit width); its value block
72
+ * sizes to its own content. Its setpoint / advice blocks stay numeric and are
73
+ * reserved normally.
49
74
  * - **Source:** the longest `src` becomes every row's source space-reserver.
50
75
  * - **Degree:** if any row has a degree, non-degree rows reserve the degree column
51
76
  * (`hasDegreeSpacer`) so their digits line up with the degree rows; the spacer is
@@ -123,11 +148,31 @@ export class ObcReadoutList extends LitElement {
123
148
  let anyDegree = false;
124
149
 
125
150
  for (const item of items) {
126
- maxFractionDigits = Math.max(maxFractionDigits, item.fractionDigits ?? 0);
151
+ // A text value renders verbatim, so it contributes nothing to the numeric
152
+ // reserve: resolving it yields `undefined` and `integerDigitCount` returns
153
+ // 0. Its row's `maxDigits` / `fractionDigits` are skipped too — they are
154
+ // ignored by a text block, so counting them would let a text row inflate
155
+ // every other row's numeric column. They DO still count when the row also
156
+ // has a setpoint or advice block, which stay numeric and are formatted by
157
+ // them. Numeric rows driven by HTML attributes resolve back to numbers
158
+ // here, so they take part as usual.
159
+ const hasNumericBlock =
160
+ !isTextValueRow(item) || item.hasSetpoint || item.hasAdvice;
161
+ if (hasNumericBlock) {
162
+ maxFractionDigits = Math.max(
163
+ maxFractionDigits,
164
+ item.fractionDigits ?? 0
165
+ );
166
+ maxIntegerDigits = Math.max(maxIntegerDigits, item.maxDigits ?? 0);
167
+ }
127
168
  maxIntegerDigits = Math.max(
128
169
  maxIntegerDigits,
129
- item.maxDigits ?? 0,
130
- integerDigitCount(item.value),
170
+ integerDigitCount(
171
+ resolveReadoutNumericValue(
172
+ item.value,
173
+ item.valueType ?? ReadoutValueType.number
174
+ )
175
+ ),
131
176
  item.hasSetpoint ? integerDigitCount(item.setpoint) : 0,
132
177
  item.hasAdvice ? integerDigitCount(item.advice) : 0
133
178
  );
@@ -155,9 +200,13 @@ export class ObcReadoutList extends LitElement {
155
200
  // Recompute every reserver / spacer on every pass (do not gate on a value
156
201
  // being present), so stale state clears when rows change — e.g. when the
157
202
  // last degree row, the last unit, or the last source is removed.
203
+ // The numeric reserve is a width in DIGITS, so applying it to a text
204
+ // block would pad short text ("Auto" padded out to "0000.0"'s width).
205
+ // Text rows hug their own content instead. Setpoint / advice stay
206
+ // numeric and keep the shared reserve even on a text row.
158
207
  item.valueOptions = {
159
208
  ...item.valueOptions,
160
- spaceReserver: numericReserver,
209
+ spaceReserver: isTextValueRow(item) ? undefined : numericReserver,
161
210
  };
162
211
  item.setpointOptions = {
163
212
  ...item.setpointOptions,
@@ -16,6 +16,7 @@ import {
16
16
  type ReadoutAdviceOptions,
17
17
  type ReadoutReserverOptions,
18
18
  type ReadoutSrcOptions,
19
+ ReadoutValueType,
19
20
  } from './readout-list-item.js';
20
21
  import type {AlertFrameConfig} from '../../components/alert-frame/alert-frame.js';
21
22
  import {
@@ -36,7 +37,8 @@ type ReadoutListItemStoryArgs = {
36
37
  unit: string;
37
38
  src: string;
38
39
  hasValue: boolean;
39
- value: number;
40
+ value: number | string;
41
+ valueType: ReadoutValueType;
40
42
  off: boolean;
41
43
  hasSetpoint: boolean;
42
44
  setpoint: number;
@@ -90,7 +92,8 @@ type ReadoutItemConfig = {
90
92
  unit?: string;
91
93
  src?: string;
92
94
  hasValue?: boolean;
93
- value?: number | null;
95
+ value?: number | string | null;
96
+ valueType?: ReadoutValueType;
94
97
  off?: boolean;
95
98
  hasSetpoint?: boolean;
96
99
  setpoint?: number;
@@ -159,6 +162,7 @@ function renderItem(config: ReadoutItemConfig) {
159
162
  .src=${config.src}
160
163
  .hasValue=${config.hasValue ?? true}
161
164
  .value=${config.value ?? null}
165
+ .valueType=${config.valueType ?? ReadoutValueType.number}
162
166
  .off=${config.off ?? false}
163
167
  .hasSetpoint=${config.hasSetpoint ?? false}
164
168
  .setpoint=${config.setpoint}
@@ -245,6 +249,7 @@ const defaultArgs: ReadoutListItemStoryArgs = {
245
249
  src: 'SRC',
246
250
  hasValue: true,
247
251
  value: 123,
252
+ valueType: ReadoutValueType.number,
248
253
  off: false,
249
254
  hasSetpoint: false,
250
255
  setpoint: 120,
@@ -313,6 +318,7 @@ const meta = {
313
318
  src: args.src,
314
319
  hasValue: args.hasValue,
315
320
  value: args.value,
321
+ valueType: args.valueType,
316
322
  off: args.off,
317
323
  hasSetpoint: args.hasSetpoint,
318
324
  setpoint: args.setpoint,
@@ -331,7 +337,16 @@ const meta = {
331
337
  hasValue: {name: 'Has Value', table: {category: 'Data'}},
332
338
  value: {
333
339
  name: 'Value',
334
- control: {type: 'number'},
340
+ // Text control (not number) so both value types are exercisable. Under
341
+ // valueType=number a numeric string resolves back to a number; entering
342
+ // non-numeric text there throws, which is the intended contract.
343
+ control: {type: 'text'},
344
+ table: {category: 'Data'},
345
+ },
346
+ valueType: {
347
+ name: 'Value Type',
348
+ control: {type: 'inline-radio'},
349
+ options: Object.values(ReadoutValueType),
335
350
  table: {category: 'Data'},
336
351
  },
337
352
  off: {name: 'Off', table: {category: 'Data'}},
@@ -461,6 +476,96 @@ export const Off: Story = {
461
476
  ]),
462
477
  };
463
478
 
479
+ /**
480
+ * **Text values** — `valueType="text"` renders `value` verbatim instead of
481
+ * formatting it as a number, for readings that are states rather than
482
+ * quantities ("Thermo On", "Auto", "Normal").
483
+ *
484
+ * The first section reproduces the reference design: two status rows sharing a
485
+ * bar, each a label with a bold text value, separated by a vertical divider.
486
+ *
487
+ * Under `valueType="text"` the numeric format options (`fractionDigits`,
488
+ * `maxDigits`, `hintedZeros`) are ignored; an explicit `spaceReserver` still
489
+ * applies. Passing text while `valueType` is `number` throws a `TypeError`
490
+ * rather than rendering `NaN`.
491
+ */
492
+ const STATUS_BAR_ROWS: {label: string; value: string}[] = [
493
+ {label: 'Operating mode', value: 'Thermo On'},
494
+ {label: 'Status', value: 'Normal'},
495
+ ];
496
+
497
+ export const TextValue: Story = {
498
+ render: () => html`
499
+ <style>
500
+ .rli-status-bar {
501
+ display: flex;
502
+ align-items: stretch;
503
+ width: 100%;
504
+ background: var(--container-background-color);
505
+ }
506
+ .rli-status-cell {
507
+ flex: 1;
508
+ display: flex;
509
+ align-items: center;
510
+ padding: 0 16px;
511
+ }
512
+ .rli-status-cell + .rli-status-cell {
513
+ border-left: 1px solid var(--border-divider-color, #ccc);
514
+ }
515
+ .rli-status-cell > obc-readout-list-item {
516
+ width: 100%;
517
+ }
518
+ .rli-text-grid {
519
+ display: grid;
520
+ grid-template-columns: repeat(2, minmax(240px, max-content));
521
+ gap: 12px 24px;
522
+ margin-top: 32px;
523
+ }
524
+ </style>
525
+ <div class="rli-status-bar">
526
+ ${STATUS_BAR_ROWS.map(
527
+ (row) => html`
528
+ <div class="rli-status-cell">
529
+ ${renderItem({
530
+ label: row.label,
531
+ value: row.value,
532
+ valueType: ReadoutValueType.text,
533
+ options: {
534
+ size: ReadoutListItemSize.medium,
535
+ value: {weight: ObcTextboxFontWeight.bold},
536
+ },
537
+ })}
538
+ </div>
539
+ `
540
+ )}
541
+ </div>
542
+ <div class="rli-text-grid">
543
+ ${[
544
+ {label: 'Mode', value: 'Auto', valueType: ReadoutValueType.text},
545
+ {
546
+ label: 'Thruster',
547
+ value: 'Standby',
548
+ valueType: ReadoutValueType.text,
549
+ },
550
+ // Numeric rows for contrast — the same component, unchanged.
551
+ {label: 'Speed', value: 12.4, valueType: ReadoutValueType.number},
552
+ // Verbatim text preserves formatting a number could not round-trip.
553
+ {label: 'Bearing', value: '1.50', valueType: ReadoutValueType.text},
554
+ ].map((row) =>
555
+ renderItem({
556
+ label: row.label,
557
+ value: row.value,
558
+ valueType: row.valueType,
559
+ options: {
560
+ size: ReadoutListItemSize.medium,
561
+ fractionDigits: 1,
562
+ },
563
+ })
564
+ )}
565
+ </div>
566
+ `,
567
+ };
568
+
464
569
  const SIZES = [
465
570
  ReadoutListItemSize.small,
466
571
  ReadoutListItemSize.medium,
@@ -1596,7 +1701,8 @@ export const MissingParts: Story = {
1596
1701
  */
1597
1702
  type AlignmentRow = {
1598
1703
  label: string;
1599
- value: number | null;
1704
+ value: number | string | null;
1705
+ valueType?: ReadoutValueType;
1600
1706
  unit: string;
1601
1707
  size?: ReadoutListItemSize;
1602
1708
  hasDegree?: boolean;
@@ -1722,6 +1828,19 @@ const ALIGNMENT_ROWS: AlignmentRow[] = [
1722
1828
  type: ObcAlertFrameType.Regular,
1723
1829
  },
1724
1830
  },
1831
+ // text value — `valueType="text"` renders verbatim and ignores maxDigits /
1832
+ // fractionDigits, but still honours an explicit `spaceReserver`. So these
1833
+ // rows hug their text in the left column and join the shared value column in
1834
+ // the right one, confirming text does not disturb the numeric alignment.
1835
+ // (Inside `obc-readout-list`, which owns the reservers, text rows are instead
1836
+ // excluded from the computed numeric width — see that component's stories.)
1837
+ {label: 'Mode', value: 'Auto', valueType: ReadoutValueType.text, unit: ''},
1838
+ {
1839
+ label: 'Thruster',
1840
+ value: 'Standby',
1841
+ valueType: ReadoutValueType.text,
1842
+ unit: '',
1843
+ },
1725
1844
  // size variants — value digit edges do NOT fully align cross-size (the degree
1726
1845
  // column scales 6/8/12px with the value size); see the ColumnAlignment doc.
1727
1846
 
@@ -1771,6 +1890,7 @@ function renderAlignmentColumn(aligned: boolean, showDebugOverlay: boolean) {
1771
1890
  unit: row.unit,
1772
1891
  src: '',
1773
1892
  value: row.value,
1893
+ valueType: row.valueType,
1774
1894
  hasSetpoint: row.hasSetpoint,
1775
1895
  setpoint: row.setpoint,
1776
1896
  hasAdvice: row.hasAdvice,
@@ -15,7 +15,12 @@ import {
15
15
  ReadoutBlockDataQuality,
16
16
  ReadoutBlockHidePhase,
17
17
  } from '../../building-blocks/readout-block/readout-block.js';
18
- import {type ReadoutNumericFormatOptions} from '../readout/readout-formatters.js';
18
+ import {
19
+ assertReadoutValueType,
20
+ resolveReadoutNumericValue,
21
+ ReadoutValueType,
22
+ type ReadoutNumericFormatOptions,
23
+ } from '../readout/readout-formatters.js';
19
24
  import {
20
25
  isDisplayedAtSetpoint,
21
26
  readoutNumericFormatOptions,
@@ -39,6 +44,9 @@ import {AlertType} from '../../types.js';
39
44
  // without a second import path.
40
45
  export {ObcTextboxFontWeight} from '../../components/textbox/textbox.js';
41
46
 
47
+ // Re-exported so consumers can type `valueType` without a second import path.
48
+ export {ReadoutValueType} from '../readout/readout-formatters.js';
49
+
42
50
  /**
43
51
  * Density/size scale of the readout row (an alias of `ReadoutBlockSize`).
44
52
  * - `small`: regular value typography (smallest, densest).
@@ -207,6 +215,8 @@ export interface ReadoutSrcOptions extends ReadoutBlockState {
207
215
  * `round-corners`, or `round` corners.
208
216
  * - **Formatting:** shared `fractionDigits`, width reservation via `maxDigits`
209
217
  * and per-segment `hintedZeros`; a `null` value renders a dash (`-`).
218
+ * - **Text values:** `valueType="text"` renders `value` verbatim (e.g.
219
+ * `"Thermo On"`) instead of formatting it as a number.
210
220
  *
211
221
  * ### Usage Guidelines
212
222
  * Use for dense readout rows in lists/tables. Prefer `<obc-readout>` for rich
@@ -242,7 +252,18 @@ export class ObcReadoutListItem extends LitElement {
242
252
  * block at full size, so the row does not shift when data arrives.
243
253
  */
244
254
  @property({type: Boolean, attribute: false}) hasValue = true;
245
- @property({type: Number}) value: number | null = null;
255
+ /**
256
+ * The value; `null` renders a dash. A number by default, or text when
257
+ * {@link valueType} is `text`.
258
+ */
259
+ @property({type: String}) value: number | string | null = null;
260
+ /**
261
+ * How {@link value} is interpreted. `number` (default) formats it via
262
+ * `fractionDigits`; `text` renders it verbatim and ignores the numeric
263
+ * format options. Passing text while this is `number` throws.
264
+ */
265
+ @property({type: String}) valueType: ReadoutValueType =
266
+ ReadoutValueType.number;
246
267
  /** Render the value as `offText` (e.g. equipment powered down). Affects the value only. */
247
268
  @property({type: Boolean}) off = false;
248
269
  /** Text shown in place of the value when `off` is true. @availableWhen off==true */
@@ -265,7 +286,17 @@ export class ObcReadoutListItem extends LitElement {
265
286
  @property({type: Boolean}) hasLeadingIcon = false;
266
287
  @property({type: Boolean}) hasDegree = false;
267
288
  @property({type: Boolean}) hasDegreeSpacer = false;
289
+ /**
290
+ * Also formats the numeric setpoint / advice blocks, which stay numeric
291
+ * even when the value is text.
292
+ * @availableWhen valueType==number || hasSetpoint==true || hasAdvice==true
293
+ */
268
294
  @property({type: Number}) fractionDigits = 0;
295
+ /**
296
+ * Also formats the numeric setpoint / advice blocks, which stay numeric
297
+ * even when the value is text.
298
+ * @availableWhen valueType==number || hasSetpoint==true || hasAdvice==true
299
+ */
269
300
  @property({type: Number}) maxDigits = 0;
270
301
  @property({type: String}) dataQuality?: ReadoutListItemDataQuality;
271
302
  // `boolean | …` (not `false | …`): the generated Angular wrapper widens a
@@ -329,8 +360,10 @@ export class ObcReadoutListItem extends LitElement {
329
360
  if (!this.hasSetpoint) {
330
361
  return false;
331
362
  }
363
+ // A text value never compares equal to a setpoint, so flip-flop / pop-up
364
+ // stay dormant for `valueType="text"`.
332
365
  return isDisplayedAtSetpoint(
333
- this.value,
366
+ resolveReadoutNumericValue(this.value, this.valueType) ?? null,
334
367
  this.setpoint,
335
368
  this.numericFormatOptions(this.resolvedMaxDigits)
336
369
  );
@@ -451,7 +484,9 @@ export class ObcReadoutListItem extends LitElement {
451
484
 
452
485
  private renderBlock(config: {
453
486
  variant: ReadoutBlockVariant;
454
- value: number | null | undefined;
487
+ value: number | string | null | undefined;
488
+ /** Only the value block is ever text; setpoint / advice stay numeric. */
489
+ valueType?: ReadoutValueType;
455
490
  valueSize: ObcTextboxSize;
456
491
  enhanced: boolean;
457
492
  weight: ObcTextboxFontWeight;
@@ -473,6 +508,7 @@ export class ObcReadoutListItem extends LitElement {
473
508
  exportparts="block, block-content, block-text, block-icon, degree"
474
509
  .variant=${config.variant}
475
510
  .value=${config.value ?? null}
511
+ .valueType=${config.valueType ?? ReadoutValueType.number}
476
512
  .size=${this.resolvedSize}
477
513
  .valueSize=${config.valueSize}
478
514
  .enhanced=${config.enhanced}
@@ -684,6 +720,7 @@ export class ObcReadoutListItem extends LitElement {
684
720
  ? this.renderBlock({
685
721
  variant: ReadoutBlockVariant.value,
686
722
  value: this.value,
723
+ valueType: this.valueType,
687
724
  valueSize: this.valueSize,
688
725
  enhanced: this.rowEnhanced,
689
726
  weight: this.valueWeight,
@@ -821,6 +858,18 @@ export class ObcReadoutListItem extends LitElement {
821
858
  `;
822
859
  }
823
860
 
861
+ protected override willUpdate(changed: Map<string, unknown>): void {
862
+ super.willUpdate(changed);
863
+ // Validated on EVERY update, deliberately NOT gated on `value`/`valueType`
864
+ // appearing in `changed`. When this assertion throws, Lit's `performUpdate`
865
+ // catch calls `__markUpdated()`, which clears the changed-properties map. A
866
+ // later update driven by any OTHER property — inside `obc-readout-list`,
867
+ // `align()` writing the shared reservers — would then see no `value` in
868
+ // `changed`, skip the check, and render the invalid value as a plain dash:
869
+ // exactly the silent failure this assertion exists to prevent.
870
+ assertReadoutValueType('obc-readout-list-item', this.value, this.valueType);
871
+ }
872
+
824
873
  override updated(changed: Map<string, unknown>): void {
825
874
  super.updated(changed);
826
875
 
@@ -15,10 +15,12 @@ import {SetpointBundle} from '../../svghelpers/setpoint-bundle.js';
15
15
  import '../watch/watch.js';
16
16
  import {
17
17
  OUTER_RING_RADIUS,
18
+ WatchCircleType,
18
19
  type WatchBarArea,
19
20
  type WatchNeedle,
20
21
  } from '../watch/watch.js';
21
22
  import {
23
+ BAND_INNER_RADIUS,
22
24
  LANE_DIVIDER_RADIUS,
23
25
  LANE_DIVIDER_WIDTH,
24
26
  PRIMARY_SUBBAND_INNER_RADIUS,
@@ -411,6 +413,21 @@ export class ObcTopViewPropulsion extends LitElement {
411
413
  ></path>`;
412
414
  }
413
415
 
416
+ /**
417
+ * Thin outline on the track band's inner edge, always shown per the
418
+ * design's track face. Drawn on the overlay because the watch paints its
419
+ * band bars over the ring circles, which would swallow a watch-side line.
420
+ */
421
+ private renderInnerRingLine() {
422
+ return svg`<circle
423
+ r=${BAND_INNER_RADIUS}
424
+ fill="none"
425
+ stroke="var(--instrument-frame-tertiary-color)"
426
+ stroke-width="1"
427
+ vector-effect="non-scaling-stroke"
428
+ ></circle>`;
429
+ }
430
+
414
431
  private renderPropeller() {
415
432
  return svg`<g transform="scale(${PROPELLER_SCALE}) translate(-80 -80)">${
416
433
  propellerImages[this.propeller]
@@ -448,6 +465,7 @@ export class ObcTopViewPropulsion extends LitElement {
448
465
  <obc-watch
449
466
  .touching=${this.touching}
450
467
  .arcFrame=${frame}
468
+ .watchCircleType=${WatchCircleType.double}
451
469
  .tickmarks=${shownTickmarks}
452
470
  .state=${this.state}
453
471
  .priority=${this.priority}
@@ -465,8 +483,8 @@ export class ObcTopViewPropulsion extends LitElement {
465
483
  .tickmarkStyle=${this.tickmarkStyle}
466
484
  ></obc-watch>
467
485
  <svg viewBox=${frame.viewBox} xmlns="http://www.w3.org/2000/svg">
468
- ${this.renderLoadingArc()} ${this.renderSecondaryArc()}
469
- ${this.renderPropeller()}
486
+ ${this.renderInnerRingLine()} ${this.renderLoadingArc()}
487
+ ${this.renderSecondaryArc()} ${this.renderPropeller()}
470
488
  </svg>
471
489
  </div>
472
490
  `;
@@ -70,6 +70,16 @@ const WIND_ICON_SCALE = 2;
70
70
  const WIND_ICON_TIP_X = 12;
71
71
  const WIND_ICON_TIP_Y = 22.4453;
72
72
 
73
+ /**
74
+ * Radial overhang of the rendered (2×, 48-unit) wind icon box past the tip
75
+ * anchor toward the watch center: the glyph tip sits at y=22.4453 of the
76
+ * 24-unit box, so the scaled box extends `(24 − 22.4453) × 2 ≈ 3.11` units
77
+ * inside the anchor radius. Add it to a desired box inner-edge radius to get
78
+ * the `windSymbolRadius` that makes the box start exactly there.
79
+ */
80
+ export const WIND_ICON_TIP_TO_BOX_INNER =
81
+ (24 - WIND_ICON_TIP_Y) * WIND_ICON_SCALE;
82
+
73
83
  const windIconCache = new Map<string, SVGTemplateResult>();
74
84
 
75
85
  function getWindIconSvg(bucket: number): SVGTemplateResult | null {