@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
@@ -1,11 +1,13 @@
1
1
  import type {Meta, StoryObj} from '@storybook/web-components-vite';
2
2
  import {html, nothing} from 'lit';
3
+ import {expect} from 'storybook/test';
3
4
  import {
4
5
  ReadoutBlockVariant,
5
6
  ReadoutBlockSize,
6
7
  ReadoutBlockDataQuality,
7
8
  ObcTextboxFontWeight,
8
9
  ObcTextboxAlignment,
10
+ ReadoutValueType,
9
11
  } from './readout-block.js';
10
12
  import './readout-block.js';
11
13
  import '../../icons/icon-placeholder.js';
@@ -19,7 +21,8 @@ const NONE = 'none';
19
21
 
20
22
  type BlockArgs = {
21
23
  variant: ReadoutBlockVariant;
22
- value: number;
24
+ value: number | string | null;
25
+ valueType: ReadoutValueType;
23
26
  size: ReadoutBlockSize;
24
27
  enhanced: boolean;
25
28
  weight: ObcTextboxFontWeight;
@@ -43,6 +46,7 @@ function renderBlock(args: Partial<BlockArgs>) {
43
46
  <obc-readout-block
44
47
  .variant=${args.variant ?? ReadoutBlockVariant.value}
45
48
  .value=${args.value ?? null}
49
+ .valueType=${args.valueType ?? ReadoutValueType.number}
46
50
  .size=${args.size ?? ReadoutBlockSize.small}
47
51
  .enhanced=${args.enhanced ?? false}
48
52
  .weight=${args.weight ?? ObcTextboxFontWeight.regular}
@@ -128,6 +132,7 @@ const meta = {
128
132
  args: {
129
133
  variant: ReadoutBlockVariant.value,
130
134
  value: 123,
135
+ valueType: ReadoutValueType.number,
131
136
  size: ReadoutBlockSize.small,
132
137
  enhanced: false,
133
138
  weight: ObcTextboxFontWeight.regular,
@@ -143,7 +148,14 @@ const meta = {
143
148
  dataQuality: NONE,
144
149
  },
145
150
  argTypes: {
146
- value: {control: {type: 'number'}},
151
+ // Text control (not number) so both value types are exercisable. Under
152
+ // valueType=number a numeric string resolves back to a number; entering
153
+ // non-numeric text there throws, which is the intended contract.
154
+ value: {control: {type: 'text'}},
155
+ valueType: {
156
+ control: {type: 'inline-radio'},
157
+ options: Object.values(ReadoutValueType),
158
+ },
147
159
  variant: {
148
160
  control: {type: 'select'},
149
161
  options: Object.values(ReadoutBlockVariant),
@@ -243,6 +255,66 @@ export const OffText: Story = {
243
255
  ]),
244
256
  };
245
257
 
258
+ /**
259
+ * `valueType="text"` renders `value` verbatim instead of formatting it as a
260
+ * number — for readings that are states rather than quantities.
261
+ *
262
+ * The numeric format options (`fractionDigits`, `maxDigits`, `hintedZeros`) are
263
+ * ignored in this mode; an explicit `spaceReserver` still applies. Passing text
264
+ * while `valueType` is `number` throws a `TypeError` rather than rendering
265
+ * `NaN`, while a numeric-looking string such as `"12.4"` is accepted and parsed
266
+ * so plain-HTML `value="12.4"` keeps working.
267
+ */
268
+ export const TextValue: Story = {
269
+ render: () =>
270
+ renderShowcase([
271
+ {
272
+ title: 'text',
273
+ args: {value: 'Auto', valueType: ReadoutValueType.text},
274
+ },
275
+ {
276
+ title: 'longer text',
277
+ args: {value: 'Thermo On', valueType: ReadoutValueType.text},
278
+ },
279
+ {
280
+ title: 'verbatim "1.50"',
281
+ args: {
282
+ value: '1.50',
283
+ valueType: ReadoutValueType.text,
284
+ fractionDigits: 1,
285
+ },
286
+ },
287
+ {
288
+ title: 'maxDigits ignored',
289
+ args: {value: 'Auto', valueType: ReadoutValueType.text, maxDigits: 4},
290
+ },
291
+ {
292
+ title: 'spaceReserver honoured',
293
+ args: {
294
+ value: 'Auto',
295
+ valueType: ReadoutValueType.text,
296
+ spaceReserver: 'Thermo On',
297
+ },
298
+ },
299
+ {
300
+ title: 'null → dash',
301
+ args: {value: null, valueType: ReadoutValueType.text},
302
+ },
303
+ {
304
+ title: 'numeric string in number mode',
305
+ args: {value: '12.4', fractionDigits: 1},
306
+ },
307
+ {
308
+ title: 'text + degree',
309
+ args: {
310
+ value: 'Auto',
311
+ valueType: ReadoutValueType.text,
312
+ hasDegree: true,
313
+ },
314
+ },
315
+ ]),
316
+ };
317
+
246
318
  /**
247
319
  * Hinted zeros pad the integer part up to `maxDigits` as muted leading zeros.
248
320
  * When enabled they take priority over `spaceReserver` (they already fill to
@@ -306,6 +378,43 @@ export const Alignment: Story = {
306
378
  ),
307
379
  };
308
380
 
381
+ /**
382
+ * Regression test for a validation hole, not a visual case.
383
+ *
384
+ * When `willUpdate` throws, Lit's `performUpdate` catch calls `__markUpdated()`,
385
+ * which clears the changed-properties map. Validation used to be gated on
386
+ * `changed.has('value') || changed.has('valueType')`, so the NEXT update —
387
+ * driven by any other property, e.g. `obc-readout-list.align()` writing the
388
+ * shared reservers — saw an empty map, skipped the check, and rendered the
389
+ * invalid value as a plain dash. Loud once, then silent forever.
390
+ *
391
+ * An EMPTY changed map is exactly what Lit leaves behind after a throw, so
392
+ * `willUpdate` is invoked directly with one. The element is deliberately left
393
+ * detached: an unconnected `LitElement` never starts its update cycle, so this
394
+ * exercises the guard without the real throw escaping the scheduler as an
395
+ * unhandled rejection.
396
+ */
397
+ export const TestValidationSurvivesUnrelatedUpdate: Story = {
398
+ render: () => html`<span>Regression test — see the play function.</span>`,
399
+ play: async () => {
400
+ type Probe = HTMLElement & {
401
+ value: number | string | null;
402
+ willUpdate: (changed: Map<string, unknown>) => void;
403
+ };
404
+ const el = document.createElement('obc-readout-block') as Probe;
405
+ const validateWithNoChanges = () => el.willUpdate(new Map());
406
+
407
+ el.value = 'Auto';
408
+ await expect(validateWithNoChanges).toThrow(/value must be a number/);
409
+
410
+ el.value = 12.4;
411
+ await expect(validateWithNoChanges).not.toThrow();
412
+
413
+ el.value = null;
414
+ await expect(validateWithNoChanges).not.toThrow();
415
+ },
416
+ };
417
+
309
418
  export const DataQuality: Story = {
310
419
  render: () =>
311
420
  renderShowcase([
@@ -318,7 +427,7 @@ export const DataQuality: Story = {
318
427
  title: 'invalid',
319
428
  args: {value: 123, dataQuality: ReadoutBlockDataQuality.invalid},
320
429
  },
321
- {title: 'null (dash)', args: {value: undefined}},
430
+ {title: 'null (dash)', args: {value: null}},
322
431
  ]),
323
432
  };
324
433
 
@@ -14,6 +14,10 @@ import '../../icons/icon-notification-advice.js';
14
14
  import {
15
15
  formatNumericValue,
16
16
  readoutFormattedInteger,
17
+ assertReadoutValueType,
18
+ resolveReadoutNumericValue,
19
+ resolveReadoutTextValue,
20
+ ReadoutValueType,
17
21
  type ReadoutNumericFormatOptions,
18
22
  } from '../../navigation-instruments/readout/readout-formatters.js';
19
23
  import {
@@ -27,6 +31,7 @@ export {
27
31
  ObcTextboxFontWeight,
28
32
  ObcTextboxAlignment,
29
33
  } from '../../components/textbox/textbox.js';
34
+ export {ReadoutValueType} from '../../navigation-instruments/readout/readout-formatters.js';
30
35
 
31
36
  /**
32
37
  * Semantic variant of the block. Drives the default marker icon and the
@@ -83,6 +88,9 @@ export enum ReadoutBlockHidePhase {
83
88
  * role default), an optional trailing degree glyph, an `off`/unavailable text
84
89
  * state, per-block data-quality and an optional per-block alert frame.
85
90
  *
91
+ * Setting `valueType` to `text` renders `value` verbatim (e.g.
92
+ * `"Auto"`) instead of a formatted number.
93
+ *
86
94
  * This is a building block used inside `obc-readout-list-item` (and, in a future
87
95
  * refactor, inside `obc-readout`); it is not normally used on its own. Colour is
88
96
  * inherited from the host context (the parent sets the role colour), so the
@@ -105,8 +113,19 @@ export class ObcReadoutBlock extends LitElement {
105
113
  @property({type: String}) variant: ReadoutBlockVariant =
106
114
  ReadoutBlockVariant.value;
107
115
 
108
- /** The numeric value; `null`/`undefined` renders a dash. */
109
- @property({type: Number}) value: number | null = null;
116
+ /**
117
+ * The value; `null`/`undefined` renders a dash. A number by default, or text
118
+ * when {@link valueType} is `text`.
119
+ */
120
+ @property({type: String}) value: number | string | null = null;
121
+
122
+ /**
123
+ * How {@link value} is interpreted. `number` (default) formats it via
124
+ * `fractionDigits`; `text` renders it verbatim and ignores the numeric
125
+ * format options. Passing text while this is `number` throws.
126
+ */
127
+ @property({type: String}) valueType: ReadoutValueType =
128
+ ReadoutValueType.number;
110
129
 
111
130
  /** Density tier — icon size, gap, degree tier. */
112
131
  @property({type: String}) size: ReadoutBlockSize = ReadoutBlockSize.small;
@@ -131,13 +150,22 @@ export class ObcReadoutBlock extends LitElement {
131
150
  /** Show the leading marker-icon container (always on for setpoint/advice). */
132
151
  @property({type: Boolean}) hasIcon = false;
133
152
 
134
- /** Number of fraction digits. */
153
+ /**
154
+ * Number of fraction digits.
155
+ * @availableWhen valueType==number
156
+ */
135
157
  @property({type: Number}) fractionDigits = 0;
136
158
 
137
- /** Integer digits to reserve / hint (independent of `fractionDigits`). */
159
+ /**
160
+ * Integer digits to reserve / hint (independent of `fractionDigits`).
161
+ * @availableWhen valueType==number
162
+ */
138
163
  @property({type: Number}) maxDigits = 0;
139
164
 
140
- /** Render muted leading zeros filling the integer part to `maxDigits`. */
165
+ /**
166
+ * Render muted leading zeros filling the integer part to `maxDigits`.
167
+ * @availableWhen valueType==number
168
+ */
141
169
  @property({type: Boolean}) hintedZeros = false;
142
170
 
143
171
  /** Explicit longest string to reserve width for (e.g. `"0000.0"`). */
@@ -280,19 +308,41 @@ export class ObcReadoutBlock extends LitElement {
280
308
  `;
281
309
  }
282
310
 
311
+ protected override willUpdate(changed: Map<string, unknown>): void {
312
+ super.willUpdate(changed);
313
+ // Validated on EVERY update, deliberately NOT gated on `value`/`valueType`
314
+ // appearing in `changed`. When this assertion throws, Lit's `performUpdate`
315
+ // catch calls `__markUpdated()`, which clears the changed-properties map. A
316
+ // later update driven by any OTHER property — inside `obc-readout-list`,
317
+ // `align()` writing the shared reservers — would then see no `value` in
318
+ // `changed`, skip the check, and render the invalid value as a plain dash:
319
+ // exactly the silent failure this assertion exists to prevent.
320
+ assertReadoutValueType('obc-readout-block', this.value, this.valueType);
321
+ }
322
+
283
323
  override render() {
284
324
  const valueSize = this.resolvedValueSize;
285
325
  const formatOptions = this.numericFormatOptions;
286
- const valueForFormat = this.value ?? undefined;
326
+ const isTextMode = this.valueType === ReadoutValueType.text;
327
+ const valueForFormat = resolveReadoutNumericValue(
328
+ this.value,
329
+ this.valueType
330
+ );
331
+ const textValue = resolveReadoutTextValue(this.value, this.valueType);
332
+ // Text mode renders verbatim and ignores the numeric format options; a
333
+ // blank / missing text value still falls back to the unavailable dash.
287
334
  const text = this.off
288
335
  ? this.offText
289
- : formatNumericValue(valueForFormat, formatOptions);
336
+ : isTextMode
337
+ ? (textValue ?? '-')
338
+ : formatNumericValue(valueForFormat, formatOptions);
290
339
  // Hinted zeros pad the INTEGER part up to `maxDigits`, independent of
291
340
  // `fractionDigits` (the decimal point and fraction digits never count toward
292
341
  // `maxDigits`). Negative / dashed values are not padded. Example: value 1.2,
293
342
  // maxDigits 3, fractionDigits 1 → "001.2".
294
343
  const hintCount =
295
344
  this.off ||
345
+ isTextMode ||
296
346
  !this.hintedZeros ||
297
347
  valueForFormat === undefined ||
298
348
  valueForFormat < 0
@@ -303,9 +353,13 @@ export class ObcReadoutBlock extends LitElement {
303
353
  // `hintedZeros` is enabled an explicit `spaceReserver` is ignored (it has
304
354
  // higher priority). Otherwise the wider of the explicit reserver and the
305
355
  // `maxDigits`-derived reserve wins.
306
- const reserver = this.hintedZeros
307
- ? this.reserverText
308
- : this.widerReserver(this.spaceReserver, this.reserverText);
356
+ // Text mode ignores the `maxDigits`-derived numeric reserve — only an
357
+ // explicit `spaceReserver` still applies.
358
+ const reserver = isTextMode
359
+ ? (this.spaceReserver ?? '')
360
+ : this.hintedZeros
361
+ ? this.reserverText
362
+ : this.widerReserver(this.spaceReserver, this.reserverText);
309
363
 
310
364
  const block = html`
311
365
  <div
@@ -262,7 +262,7 @@ export class ObcCompass extends LitElement {
262
262
  /** @availableWhen priority==enhanced */
263
263
  @property({type: Array, attribute: false})
264
264
  priorityElements: CompassPriorityElement[] = [CompassPriorityElement.hdg];
265
- /** Show compass NSEW labels and north arrow. */
265
+ /** Show compass NSEW labels. The north arrow is always shown, independent of this flag. */
266
266
  @property({type: Boolean}) showLabels: boolean = false;
267
267
  /** When true, labels and north arrow are placed inside the outer ring. */
268
268
  @property({type: Boolean}) tickmarksInside: boolean = false;
@@ -424,7 +424,7 @@ export class ObcCompass extends LitElement {
424
424
  .tickmarksInside=${this.tickmarksInside}
425
425
  .crosshairEnabled=${true}
426
426
  .crosshairCenterCutout=${this.hasCenterReadouts}
427
- .northArrow=${!frame.labelsHidden}
427
+ .northArrow=${true}
428
428
  .angleSetpoint=${this.headingSetpoint ?? undefined}
429
429
  .newAngleSetpoint=${this.newHeadingSetpoint}
430
430
  .atAngleSetpoint=${this._headingSp.computeAtSetpoint(this.heading)}
@@ -38,6 +38,21 @@ const meta: Meta<typeof ObcCurrent> = {
38
38
  control: 'select',
39
39
  options: topVessels,
40
40
  },
41
+ hasPattern: {control: 'boolean'},
42
+ waveLength: {
43
+ control: {type: 'range', min: 0.25, max: 3, step: 0.05},
44
+ description:
45
+ 'Wavelength multiplier on the design band spacing (1 = design).',
46
+ },
47
+ waveHeight: {
48
+ control: {type: 'range', min: 0, max: 1, step: 0.05},
49
+ description: 'Peak opacity the pattern bands fade up to.',
50
+ },
51
+ waveSpeed: {
52
+ control: {type: 'range', min: -2, max: 2, step: 0.1},
53
+ description:
54
+ 'Pattern drift in wavelengths per second, with the flow (0 = static).',
55
+ },
41
56
  },
42
57
  } satisfies Meta<ObcCurrent>;
43
58
 
@@ -59,3 +74,21 @@ export const VesselEnhanced: Story = {
59
74
  export const DirectionEnhanced: Story = {
60
75
  args: {type: CurrentType.direction, priority: Priority.enhanced},
61
76
  };
77
+
78
+ export const WithoutPattern: Story = {
79
+ args: {hasPattern: false},
80
+ };
81
+
82
+ /** Denser, softer waves: half the design wavelength at reduced intensity. */
83
+ export const WaveTuned: Story = {
84
+ args: {waveLength: 0.5, waveHeight: 0.6},
85
+ };
86
+
87
+ /**
88
+ * The pattern drifts with the flow at `waveSpeed` wavelengths per second
89
+ * (excluded from visual tests because it animates continuously).
90
+ */
91
+ export const AnimatedWaves: Story = {
92
+ tags: ['skip-test'],
93
+ args: {waveSpeed: 0.4},
94
+ };
@@ -18,8 +18,14 @@ export enum CurrentType {
18
18
 
19
19
  const CURRENT_PATTERN_SCALE_VESSEL = 1.39;
20
20
  const CURRENT_PATTERN_SCALE_DIRECTION = 2.0;
21
- /** Chevron-tip anchor radius for the peripheral icon (vessel type), from Figma. */
22
- const CURRENT_ICON_PERIPHERY_RADIUS = 116;
21
+ /**
22
+ * Chevron-tip anchor radius for the peripheral icon (vessel type): the tip
23
+ * sits on the track band's inner edge (112), so the 48-unit glyph box fills
24
+ * the band (112–160) exactly, per the OpenBridge 6.1 design.
25
+ */
26
+ const CURRENT_ICON_PERIPHERY_RADIUS = innerRingRadiusFor(
27
+ WatchCircleType.double
28
+ );
23
29
  /** Scale of the centered 24-unit chevron glyph (direction type), from Figma (168/24). */
24
30
  const CURRENT_DIRECTION_ICON_SCALE = 7;
25
31
 
@@ -56,6 +62,9 @@ export function clampCurrentSpeed(value: number | null): number | null {
56
62
  * the enhanced (blue) palette.
57
63
  * - **Vessel image**: configurable silhouette rotating with
58
64
  * `vesselHeadingDeg` (vessel type).
65
+ * - **Pattern toggle & wave tuning**: `hasPattern` switches the band pattern
66
+ * off; `waveLength`, `waveHeight` and `waveSpeed` tune the band spacing,
67
+ * intensity and drift motion.
59
68
  *
60
69
  * ## Usage Guidelines
61
70
  *
@@ -89,6 +98,28 @@ export class ObcCurrent extends LitElement {
89
98
  @property({type: Number}) currentFromDirection: number | null = null;
90
99
  /** Color priority: `Priority.enhanced` uses the blue/enhanced palette (default: `Priority.regular`). */
91
100
  @property({type: String}) priority: Priority = Priority.regular;
101
+ /**
102
+ * Show the force-graphics band pattern behind the watch face.
103
+ * @availableWhen currentFromDirection!=null
104
+ */
105
+ @property({type: Boolean, attribute: false}) hasPattern = true;
106
+ /**
107
+ * Wavelength of the pattern bands as a multiplier on the design spacing
108
+ * (1 = design, 0.5 = twice as dense).
109
+ * @availableWhen hasPattern==true && currentFromDirection!=null
110
+ */
111
+ @property({type: Number}) waveLength = 1;
112
+ /**
113
+ * Wave intensity: the peak opacity the pattern bands fade up to (0–1).
114
+ * @availableWhen hasPattern==true && currentFromDirection!=null
115
+ */
116
+ @property({type: Number}) waveHeight = 1;
117
+ /**
118
+ * Drift speed of the pattern in wavelengths per second, moving with the
119
+ * flow (negative drifts against it); 0 keeps the pattern static.
120
+ * @availableWhen hasPattern==true && currentFromDirection!=null
121
+ */
122
+ @property({type: Number}) waveSpeed = 0;
92
123
  /**
93
124
  * The image of the vessel.
94
125
  * @availableWhen type==vessel
@@ -148,7 +179,7 @@ export class ObcCurrent extends LitElement {
148
179
  }
149
180
 
150
181
  private renderForcePattern(watchCircleType: WatchCircleType) {
151
- if (this.currentFromDirection == null) {
182
+ if (!this.hasPattern || this.currentFromDirection == null) {
152
183
  return nothing;
153
184
  }
154
185
  return html`
@@ -162,6 +193,9 @@ export class ObcCurrent extends LitElement {
162
193
  color: this.enhanced
163
194
  ? 'var(--instrument-enhanced-tertiary-color)'
164
195
  : undefined,
196
+ waveLength: this.waveLength,
197
+ waveHeight: this.waveHeight,
198
+ waveSpeed: this.waveSpeed,
165
199
  })}
166
200
  </svg>
167
201
  `;
@@ -128,12 +128,14 @@ export const WithVectorArrows: Story = {
128
128
 
129
129
  /**
130
130
  * Below the label-reserve cap (~200px for the NSWE + north-arrow decor) the
131
- * frame reports `labelsHidden` and the instrument drops the labels and the
132
- * north arrow instead of letting them clip — the same graceful degradation
133
- * the tick-label instruments use (issue #1021).
131
+ * frame reports `labelsHidden` and the instrument drops the labels instead
132
+ * of letting them clip — the same graceful degradation the tick-label
133
+ * instruments use (issue #1021). The north arrow stays visible on even the
134
+ * smallest face: below the small-scale threshold it renders as a compact
135
+ * triangle at the ring that scales with the face, so it never clips.
134
136
  */
135
137
  export const SmallContainerDegraded: Story = {
136
- name: 'Small Container (160px, Labels + Arrow Hidden Past Cap)',
138
+ name: 'Small Container (160px, Labels Hidden Past Cap)',
137
139
  args: {
138
140
  width: 160,
139
141
  },
@@ -309,7 +309,7 @@ export class ObcHeading extends LitElement {
309
309
  .tickmarksInside=${this.tickmarksInside}
310
310
  .crosshairEnabled=${true}
311
311
  .crosshairCenterCutout=${this.hasCenterReadouts}
312
- .northArrow=${!frame.labelsHidden}
312
+ .northArrow=${true}
313
313
  .angleSetpoint=${this.headingSetpoint ?? undefined}
314
314
  .newAngleSetpoint=${this.newHeadingSetpoint}
315
315
  .atAngleSetpoint=${this._headingSp.computeAtSetpoint(this.heading)}
@@ -351,9 +351,16 @@ export class ObcPitchRollHeave extends LitElement {
351
351
  * `obc-watch` renders its crosshair after the bands, i.e. on top of them.
352
352
  * Drawing it here puts it under the watch layer, so the bands and the heave
353
353
  * column mask it naturally.
354
+ *
355
+ * In `single-scale` nothing frames the segment above the vessel (the other
356
+ * three arms end under the pitch band, roll band and heave column), so the
357
+ * vertical arm stops at the centre instead of continuing upward. In
358
+ * `dual-scale` the top roll band gives it a natural endpoint, so the full
359
+ * arm is kept.
354
360
  */
355
361
  private renderCrosshair(): SVGTemplateResult {
356
362
  const r = OUTER_RING_RADIUS;
363
+ const yTop = this.isDualScale ? -r : 0;
357
364
  return svg`
358
365
  <line
359
366
  x1=${-r} y1="0" x2=${r} y2="0"
@@ -361,7 +368,7 @@ export class ObcPitchRollHeave extends LitElement {
361
368
  vector-effect="non-scaling-stroke"
362
369
  />
363
370
  <line
364
- x1="0" y1=${-r} x2="0" y2=${r}
371
+ x1="0" y1=${yTop} x2="0" y2=${r}
365
372
  stroke="var(--instrument-frame-tertiary-color)"
366
373
  vector-effect="non-scaling-stroke"
367
374
  />
@@ -131,7 +131,7 @@ export const WithTrackBar: Story = {
131
131
  hasReadout: true,
132
132
  rateOfTurnDegreesPerMinute: 45,
133
133
  rotPosition: RotPosition.innerCircle,
134
- watchCircleType: WatchCircleType.double,
134
+ watchCircleType: WatchCircleType.triple,
135
135
  },
136
136
  };
137
137
 
@@ -142,7 +142,7 @@ export const WithTrackBarEnhanced: Story = {
142
142
  hasReadout: true,
143
143
  rateOfTurnDegreesPerMinute: 45,
144
144
  rotPosition: RotPosition.innerCircle,
145
- watchCircleType: WatchCircleType.double,
145
+ watchCircleType: WatchCircleType.triple,
146
146
  priority: Priority.enhanced,
147
147
  },
148
148
  };
@@ -154,7 +154,7 @@ export const WithTrackBarPortStarboard: Story = {
154
154
  hasReadout: true,
155
155
  rateOfTurnDegreesPerMinute: 45,
156
156
  rotPosition: RotPosition.innerCircle,
157
- watchCircleType: WatchCircleType.double,
157
+ watchCircleType: WatchCircleType.triple,
158
158
  rotPortStarboard: true,
159
159
  },
160
160
  };
@@ -35,7 +35,8 @@ export {RotType, RotPosition};
35
35
  * - **Track bar** (`hasTrackBar`): A bar in the ring band growing from the
36
36
  * twelve o'clock position to the measured rate of turn, with a needle
37
37
  * marker at its end and sector tickmarks (pairs with
38
- * `watchCircleType="double"` for the banded face).
38
+ * `watchCircleType="double"` for the banded face, or `"triple"` when the
39
+ * ROT dots sit on the inner circle).
39
40
  * - **Center readout** (`hasReadout`): A centered readout showing the
40
41
  * measured rate of turn (label `ROT`, unit `DEG/min` by default).
41
42
  * - **Color priority**: Uses `priority` to select regular or enhanced color
@@ -91,7 +92,9 @@ export class ObcRateOfTurn extends LitElement {
91
92
  * When `true`, shows a bar in the ring band from the twelve o'clock
92
93
  * position to the measured rate of turn, with a needle marker at its end
93
94
  * and sector tickmarks. Pairs with `watchCircleType="double"` for the
94
- * banded face. Driven by `rateOfTurnDegreesPerMinute` only.
95
+ * banded face or `"triple"` when `rotPosition` is `innerCircle`, so the
96
+ * dots get their inner track too. Driven by `rateOfTurnDegreesPerMinute`
97
+ * only.
95
98
  */
96
99
  @property({type: Boolean}) hasTrackBar: boolean = false;
97
100
  /**