@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.101 → 2.0.0-next.103

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 (110) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +580 -143
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +629 -407
  4. package/dist/bars-graphs/donut-chart/donut-chart.d.ts +18 -16
  5. package/dist/bars-graphs/donut-chart/donut-chart.d.ts.map +1 -1
  6. package/dist/bars-graphs/donut-chart/donut-chart.js.map +1 -1
  7. package/dist/bars-graphs/pie-chart/pie-chart.d.ts +14 -12
  8. package/dist/bars-graphs/pie-chart/pie-chart.d.ts.map +1 -1
  9. package/dist/bars-graphs/pie-chart/pie-chart.js.map +1 -1
  10. package/dist/bars-graphs/polar-chart/polar-chart.d.ts +19 -16
  11. package/dist/bars-graphs/polar-chart/polar-chart.d.ts.map +1 -1
  12. package/dist/bars-graphs/polar-chart/polar-chart.js.map +1 -1
  13. package/dist/bars-graphs/radial-bar-chart/radial-bar-chart.d.ts +8 -8
  14. package/dist/bars-graphs/radial-bar-chart/radial-bar-chart.d.ts.map +1 -1
  15. package/dist/bars-graphs/radial-bar-chart/radial-bar-chart.js.map +1 -1
  16. package/dist/building-blocks/chart-line/chart-line-base.d.ts +8 -25
  17. package/dist/building-blocks/chart-line/chart-line-base.d.ts.map +1 -1
  18. package/dist/building-blocks/chart-line/chart-line-base.js.map +1 -1
  19. package/dist/building-blocks/instrument-radial/instrument-radial.d.ts +22 -2
  20. package/dist/building-blocks/instrument-radial/instrument-radial.d.ts.map +1 -1
  21. package/dist/building-blocks/instrument-radial/instrument-radial.js +61 -37
  22. package/dist/building-blocks/instrument-radial/instrument-radial.js.map +1 -1
  23. package/dist/integration-systems/integration-bar/integration-bar.d.ts +20 -24
  24. package/dist/integration-systems/integration-bar/integration-bar.d.ts.map +1 -1
  25. package/dist/integration-systems/integration-bar/integration-bar.js.map +1 -1
  26. package/dist/integration-systems/integration-button/integration-button.d.ts +15 -13
  27. package/dist/integration-systems/integration-button/integration-button.d.ts.map +1 -1
  28. package/dist/integration-systems/integration-button/integration-button.js.map +1 -1
  29. package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.d.ts +14 -0
  30. package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.d.ts.map +1 -1
  31. package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.js +30 -11
  32. package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.js.map +1 -1
  33. package/dist/navigation-instruments/compass/compass.d.ts +68 -31
  34. package/dist/navigation-instruments/compass/compass.d.ts.map +1 -1
  35. package/dist/navigation-instruments/compass/compass.js +36 -16
  36. package/dist/navigation-instruments/compass/compass.js.map +1 -1
  37. package/dist/navigation-instruments/compass-flat/compass-flat.d.ts +8 -9
  38. package/dist/navigation-instruments/compass-flat/compass-flat.d.ts.map +1 -1
  39. package/dist/navigation-instruments/compass-flat/compass-flat.js.map +1 -1
  40. package/dist/navigation-instruments/compass-sector/compass-sector.d.ts.map +1 -1
  41. package/dist/navigation-instruments/compass-sector/compass-sector.js.map +1 -1
  42. package/dist/navigation-instruments/gauge-radial/gauge-radial.css.js +34 -2
  43. package/dist/navigation-instruments/gauge-radial/gauge-radial.css.js.map +1 -1
  44. package/dist/navigation-instruments/gauge-radial/gauge-radial.d.ts +15 -0
  45. package/dist/navigation-instruments/gauge-radial/gauge-radial.d.ts.map +1 -1
  46. package/dist/navigation-instruments/gauge-radial/gauge-radial.js +35 -1
  47. package/dist/navigation-instruments/gauge-radial/gauge-radial.js.map +1 -1
  48. package/dist/navigation-instruments/gauge-trend/gauge-trend.d.ts +0 -5
  49. package/dist/navigation-instruments/gauge-trend/gauge-trend.d.ts.map +1 -1
  50. package/dist/navigation-instruments/gauge-trend/gauge-trend.js.map +1 -1
  51. package/dist/navigation-instruments/graph-mini/graph-mini.d.ts +1 -1
  52. package/dist/navigation-instruments/graph-mini/graph-mini.d.ts.map +1 -1
  53. package/dist/navigation-instruments/graph-mini/graph-mini.js.map +1 -1
  54. package/dist/navigation-instruments/heading/heading.d.ts +19 -1
  55. package/dist/navigation-instruments/heading/heading.d.ts.map +1 -1
  56. package/dist/navigation-instruments/heading/heading.js +34 -16
  57. package/dist/navigation-instruments/heading/heading.js.map +1 -1
  58. package/dist/navigation-instruments/indicator-graph/indicator-graph.d.ts +1 -1
  59. package/dist/navigation-instruments/indicator-graph/indicator-graph.d.ts.map +1 -1
  60. package/dist/navigation-instruments/indicator-graph/indicator-graph.js.map +1 -1
  61. package/dist/navigation-instruments/rudder/rudder.d.ts +15 -2
  62. package/dist/navigation-instruments/rudder/rudder.d.ts.map +1 -1
  63. package/dist/navigation-instruments/rudder/rudder.js +36 -26
  64. package/dist/navigation-instruments/rudder/rudder.js.map +1 -1
  65. package/dist/navigation-instruments/speed-gauge/speed-gauge.d.ts +15 -1
  66. package/dist/navigation-instruments/speed-gauge/speed-gauge.d.ts.map +1 -1
  67. package/dist/navigation-instruments/speed-gauge/speed-gauge.js +31 -3
  68. package/dist/navigation-instruments/speed-gauge/speed-gauge.js.map +1 -1
  69. package/dist/navigation-instruments/watch/watch.d.ts +45 -17
  70. package/dist/navigation-instruments/watch/watch.d.ts.map +1 -1
  71. package/dist/navigation-instruments/watch/watch.js +53 -38
  72. package/dist/navigation-instruments/watch/watch.js.map +1 -1
  73. package/dist/svghelpers/radial-frame.d.ts +122 -0
  74. package/dist/svghelpers/radial-frame.d.ts.map +1 -0
  75. package/dist/svghelpers/radial-frame.js +259 -0
  76. package/dist/svghelpers/radial-frame.js.map +1 -0
  77. package/package.json +1 -1
  78. package/script/check-slot-event-docs.ts +35 -0
  79. package/src/bars-graphs/donut-chart/donut-chart.ts +18 -16
  80. package/src/bars-graphs/pie-chart/pie-chart.ts +14 -12
  81. package/src/bars-graphs/polar-chart/polar-chart.ts +19 -16
  82. package/src/bars-graphs/radial-bar-chart/radial-bar-chart.ts +8 -8
  83. package/src/building-blocks/chart-line/chart-line-base.ts +8 -25
  84. package/src/building-blocks/instrument-radial/instrument-radial.stories.ts +187 -6
  85. package/src/building-blocks/instrument-radial/instrument-radial.ts +98 -47
  86. package/src/integration-systems/integration-bar/integration-bar.ts +20 -24
  87. package/src/integration-systems/integration-button/integration-button.ts +15 -13
  88. package/src/navigation-instruments/azimuth-thruster/azimuth-thruster.stories.ts +78 -1
  89. package/src/navigation-instruments/azimuth-thruster/azimuth-thruster.ts +55 -11
  90. package/src/navigation-instruments/compass/compass.stories.ts +88 -1
  91. package/src/navigation-instruments/compass/compass.ts +99 -46
  92. package/src/navigation-instruments/compass-flat/compass-flat.ts +8 -9
  93. package/src/navigation-instruments/compass-sector/compass-sector.ts +7 -0
  94. package/src/navigation-instruments/gauge-radial/gauge-radial.css +32 -2
  95. package/src/navigation-instruments/gauge-radial/gauge-radial.stories.ts +265 -1
  96. package/src/navigation-instruments/gauge-radial/gauge-radial.ts +59 -1
  97. package/src/navigation-instruments/gauge-trend/gauge-trend.ts +0 -5
  98. package/src/navigation-instruments/graph-mini/graph-mini.ts +1 -1
  99. package/src/navigation-instruments/heading/heading.stories.ts +92 -1
  100. package/src/navigation-instruments/heading/heading.ts +50 -16
  101. package/src/navigation-instruments/indicator-graph/indicator-graph.ts +1 -1
  102. package/src/navigation-instruments/rudder/rudder.stories.ts +83 -1
  103. package/src/navigation-instruments/rudder/rudder.ts +61 -34
  104. package/src/navigation-instruments/speed-gauge/speed-gauge.stories.ts +94 -1
  105. package/src/navigation-instruments/speed-gauge/speed-gauge.ts +57 -4
  106. package/src/navigation-instruments/watch/watch.stories.ts +140 -1
  107. package/src/navigation-instruments/watch/watch.ts +100 -60
  108. package/src/storybook-util.ts +59 -0
  109. package/src/svghelpers/radial-frame.spec.ts +307 -0
  110. package/src/svghelpers/radial-frame.ts +508 -0
@@ -6,7 +6,12 @@ import {
6
6
  WatchCircleType,
7
7
  } from './watch.js';
8
8
  import './watch.js';
9
- import {widthDecorator} from '../../storybook-util.js';
9
+ import {
10
+ playgroundColumn,
11
+ resizableStoryBox,
12
+ storyHint,
13
+ widthDecorator,
14
+ } from '../../storybook-util.js';
10
15
  import {AdviceState, AdviceType} from './advice.js';
11
16
  import {InstrumentState, Priority} from '../types.js';
12
17
  import {TickmarkType} from './tickmark.js';
@@ -70,6 +75,11 @@ Source of truth: \`packages/openbridge-webcomponents/src/navigation-instruments/
70
75
  },
71
76
  areas: {control: {type: 'object'}},
72
77
  padding: {control: {type: 'range', min: 0, max: 100, step: 1}},
78
+ faceDiameter: {
79
+ control: {type: 'range', min: 100, max: 600, step: 10},
80
+ description:
81
+ 'Pins the outer-ring diameter in px (fixed intrinsic size, equal circumference across instruments). Clear to return to fill-the-container sizing.',
82
+ },
73
83
  vessels: {control: {type: 'object'}},
74
84
  windKnots: {control: {type: 'range', min: 0, max: 100, step: 1}},
75
85
  windFromDirectionDeg: {control: {type: 'range', min: 0, max: 360, step: 1}},
@@ -729,6 +739,135 @@ export const TickmarksTestInsideRotation: Story = {
729
739
  },
730
740
  };
731
741
 
742
+ /**
743
+ * Regression test for label counter-scaling after a container resize: the
744
+ * wrapper starts at 400px and `play` shrinks it to 240px BEFORE the snapshot.
745
+ * The standalone watch host renders inline (no box), so its ResizeObserver
746
+ * only fires via the internal `<svg>` (`observeInnerBox`) — if that
747
+ * observation is lost, `--scale` stays at the 400px value and the labels
748
+ * shrink with the box (~7px on screen) instead of holding ~12px.
749
+ */
750
+ export const CounterScaleAfterResize: Story = {
751
+ args: {
752
+ tickmarks: [
753
+ ...Array.from({length: 24}, (_, i) => ({
754
+ angle: i * 15,
755
+ type: TickmarkType.secondary,
756
+ text: `${i * 15}`,
757
+ })),
758
+ ],
759
+ },
760
+ parameters: {widthDecorator: false},
761
+ render: (args) => html`
762
+ <div id="counter-scale-wrap" style="width: 400px; height: 400px;">
763
+ <obc-watch .tickmarks=${args.tickmarks}></obc-watch>
764
+ </div>
765
+ `,
766
+ play: async ({canvasElement}) => {
767
+ const wrap = canvasElement.querySelector(
768
+ '#counter-scale-wrap'
769
+ ) as HTMLElement;
770
+ wrap.style.width = '240px';
771
+ wrap.style.height = '240px';
772
+ await new Promise((resolve) =>
773
+ requestAnimationFrame(() => requestAnimationFrame(resolve))
774
+ );
775
+ await wrap.querySelector('obc-watch')?.updateComplete;
776
+ },
777
+ };
778
+
779
+ type SizingPlaygroundArgs = Partial<ObcWatch> & {
780
+ lockFaceDiameter?: boolean;
781
+ };
782
+
783
+ /**
784
+ * Interactive sizing playground for the core renderer: drag the dashed box's
785
+ * bottom-right corner to resize it. The first watch (2-digit tick labels) is
786
+ * pinned to a fixed intrinsic size by the `faceDiameter` control, while the
787
+ * 4-digit watch and the 180° arc adapt to the remaining flex space, reserving
788
+ * label room adaptively (and hiding labels past the reserve cap in
789
+ * `svghelpers/radial-frame.ts`, issue #1021). Enable `lockFaceDiameter` to
790
+ * pin all three to the same circumference. Related: *Sizing Playground*
791
+ * stories under Building Blocks/Instrument Radial and Instruments/Gauge
792
+ * Radial.
793
+ */
794
+ export const SizingPlayground: StoryObj<SizingPlaygroundArgs> = {
795
+ name: 'Sizing Playground — FaceDiameter + Resizable (Manual)',
796
+ tags: ['skip-test'],
797
+ parameters: {widthDecorator: false},
798
+ args: {
799
+ faceDiameter: 200,
800
+ lockFaceDiameter: false,
801
+ },
802
+ argTypes: {
803
+ lockFaceDiameter: {
804
+ control: 'boolean',
805
+ description:
806
+ 'Apply faceDiameter to every instance (equal circumference) instead of only the first.',
807
+ },
808
+ },
809
+ render: (args) => {
810
+ const fullCircleTicks = (factor: number) =>
811
+ Array.from({length: 12}, (_, i) => ({
812
+ angle: i * 30,
813
+ type: TickmarkType.secondary,
814
+ text: `${i * 30 * factor}`,
815
+ }));
816
+ const instances = [
817
+ {label: '2-digit ticks', ticks: fullCircleTicks(1), areas: undefined},
818
+ {label: '4-digit ticks', ticks: fullCircleTicks(10), areas: undefined},
819
+ {
820
+ label: '180° arc',
821
+ ticks: Array.from({length: 7}, (_, i) => ({
822
+ angle: i * 30 - 90,
823
+ type: TickmarkType.secondary,
824
+ text: `${i * 30}`,
825
+ })),
826
+ areas: [
827
+ {
828
+ startAngle: -90,
829
+ endAngle: 90,
830
+ roundInsideCut: true,
831
+ roundOutsideCut: true,
832
+ },
833
+ ],
834
+ },
835
+ ];
836
+ const fd = (index: number) =>
837
+ index === 0 || args.lockFaceDiameter ? args.faceDiameter : undefined;
838
+ const caption = (index: number, label: string) =>
839
+ fd(index) !== undefined
840
+ ? `${label} — pinned ${fd(index)}px`
841
+ : `${label} — adaptive (flex)`;
842
+ return html`
843
+ ${storyHint(
844
+ 'Drag the bottom-right corner of the dashed box to resize it. The first watch is pinned by the faceDiameter control; the 4-digit watch and the 180° arc adapt to the remaining flex space. Enable lockFaceDiameter to pin all three to the same circumference.'
845
+ )}
846
+ ${resizableStoryBox(
847
+ html`
848
+ ${instances.map((g, index) =>
849
+ playgroundColumn(
850
+ caption(index, g.label),
851
+ html`
852
+ <obc-watch
853
+ .faceDiameter=${fd(index)}
854
+ .tickmarks=${g.ticks}
855
+ .areas=${g.areas ?? []}
856
+ .watchCircleType=${g.areas
857
+ ? WatchCircleType.double
858
+ : WatchCircleType.single}
859
+ ></obc-watch>
860
+ `,
861
+ {pinned: fd(index) !== undefined}
862
+ )
863
+ )}
864
+ `,
865
+ {width: 760, height: 320}
866
+ )}
867
+ `;
868
+ },
869
+ };
870
+
732
871
  /**
733
872
  * Side-by-side comparison of setpoint visual states for radial instruments.
734
873
  *
@@ -52,10 +52,16 @@ import {
52
52
  import {VesselImage, VesselImageSize, vesselImages} from './vessel.js';
53
53
  import {renderCurrent, renderWind} from './environment.js';
54
54
  import {customElement} from '../../decorator.js';
55
+ import {type ZoomToFitArcFrame} from '../../svghelpers/arc-frame.js';
55
56
  import {
56
- computeZoomToFitArcFrame,
57
- type ZoomToFitArcFrame,
58
- } from '../../svghelpers/arc-frame.js';
57
+ applyPinnedHostSize,
58
+ computeRadialFrame,
59
+ estimateLabelWidthPx,
60
+ measureContainerPx,
61
+ NSWE_LABEL_WIDTH_PX,
62
+ observeInnerBox,
63
+ type RadialFrame,
64
+ } from '../../svghelpers/radial-frame.js';
59
65
  export {VesselImage, VesselImageSize, vesselImages};
60
66
 
61
67
  export enum WatchCircleType {
@@ -161,22 +167,6 @@ const RADIAL_SETPOINT_INWARD_ADJUST = 4;
161
167
  * `_setpointCssAngle` tracks the accumulated CSS angle to avoid long-way-around
162
168
  * transitions across the 0°/360° boundary.
163
169
  *
164
- * @property {InstrumentState} state - Instrument state (active, loading, off)
165
- * @property {Priority} priority - Color priority (enhanced = blue palette, regular = gray palette)
166
- * @property {number|undefined} angleSetpoint - Setpoint angle in degrees (0° = 12 o'clock)
167
- * @property {number|undefined} newAngleSetpoint - New setpoint being adjusted (focus mode)
168
- * @property {boolean} atAngleSetpoint - Whether value matches setpoint (within deadband)
169
- * @property {number} angleSetpointAtZeroDeadband - Deadband for zero detection (default 0.5°)
170
- * @property {boolean} setpointOverride - Override to derive setpoint color from priority regardless of state
171
- * @property {RotType|undefined} rotType - ROT visualization type: `'dots'` (spinning dots) or `'bar'` (arc bar with clipped dots). Undefined hides the ROT layer.
172
- * @property {RotPosition} rotPosition - Track on which ROT elements are placed: `'scale'` (on the outer ring) or `'innerCircle'` (default, inside the inner ring)
173
- * @property {number} rotStartAngle - Start angle of the ROT bar arc in degrees (0° = 12 o'clock, clockwise). Only used when `rotType` is `'bar'`.
174
- * @property {number} rotEndAngle - End angle of the ROT bar arc in degrees. The bar is hidden when the difference from `rotStartAngle` is less than 0.1°.
175
- * @property {Priority|undefined} rotPriority - Override priority for ROT color derivation. When set, ROT colors use this instead of the main `priority`. Useful when the ROT element has independent priority (e.g. compass per-element priority).
176
- * @property {number|undefined} rateOfTurnDegreesPerMinute - Measured rate of turn in degrees per minute (the maritime/AIS convention, see ES-TRIN 2025/1 Art. 3.02 and ITU-R M.1371). Sign controls direction (positive = starboard/clockwise). When defined, this drives both the dot animation (multiplied by `rotDotAnimationFactor`) and the port/starboard direction sign.
177
- * @property {number} rotDotAnimationFactor - Visual amplification factor applied only to the spinning-dot animation (not to bar extent). Default `18` keeps the legacy visual feel (≈1 rpm at 20°/min).
178
- * @property {number} rotationsPerMinute - **Deprecated.** Spin speed of the ROT dot ring in rotations per minute. Sign controls direction (positive = clockwise). Use `rateOfTurnDegreesPerMinute` instead.
179
- * @property {ZoomToFitArcFrame|undefined} arcFrame - Pre-computed zoom-to-fit arc frame. When set, the watch skips its own `computeZoomToFitArcFrame()` call and uses these values directly. Consumer instruments (e.g. rudder, instrument-radial) should compute the frame once and pass it here to avoid redundant computation. If you pass `arcFrame`, you own keeping it in sync with `areas` / `watchCircleType` — obc-watch will NOT recompute it, so a stale frame renders stale geometry.
180
170
  * @experimental
181
171
  */
182
172
  @customElement('obc-watch')
@@ -184,16 +174,23 @@ export class ObcWatch extends LitElement {
184
174
  private _setpointId = `watch-setpoint-${Math.random().toString(36).slice(2, 9)}`;
185
175
  private _newSetpointId = `watch-new-setpoint-${Math.random().toString(36).slice(2, 9)}`;
186
176
 
177
+ /** Instrument state (active, loading, off) */
187
178
  @property({type: String}) state: InstrumentState = InstrumentState.active;
179
+ /** Color priority (enhanced = blue palette, regular = gray palette) */
188
180
  @property({type: String}) priority: Priority = Priority.regular;
189
181
  @property({type: String}) watchCircleType: WatchCircleType =
190
182
  WatchCircleType.single;
191
183
  @property({type: Boolean}) northArrow: boolean = false;
192
184
  @property({type: Boolean}) northArrowInside: boolean | undefined;
185
+ /** Setpoint angle in degrees (0° = 12 o'clock) */
193
186
  @property({type: Number}) angleSetpoint: number | undefined;
187
+ /** New setpoint being adjusted (focus mode) */
194
188
  @property({type: Number}) newAngleSetpoint: number | undefined;
189
+ /** Whether value matches setpoint (within deadband) */
195
190
  @property({type: Boolean}) atAngleSetpoint: boolean = false;
191
+ /** Deadband for zero detection (default 0.5°) */
196
192
  @property({type: Number}) angleSetpointAtZeroDeadband: number = 0.5;
193
+ /** Override to derive setpoint color from priority regardless of state */
197
194
  @property({type: Boolean}) setpointOverride: boolean = false;
198
195
  @property({type: Boolean}) touching: boolean = false;
199
196
 
@@ -211,7 +208,21 @@ export class ObcWatch extends LitElement {
211
208
 
212
209
  /** Whether the setpoint CSS angle has been initialised (to skip transition on first render). */
213
210
  private _setpointCssAngleInit = false;
211
+ /**
212
+ * Explicit padding override in SVG units: the un-zoomed viewBox becomes
213
+ * exactly `(176 + padding) * 2`. Setting it disables the automatic
214
+ * width-aware label reserve (issue #1021) — the caller owns label room.
215
+ */
214
216
  @property({type: Number}) padding: number | undefined;
217
+ /**
218
+ * Outer-ring diameter in CSS pixels. When set, the instrument renders at a
219
+ * fixed intrinsic size derived from the ring, arc shape and label reserve —
220
+ * so instruments sharing the same value have identical ring circumference
221
+ * regardless of label width or arc extent (like obc-donut-chart's
222
+ * fixedHeight). When unset (default), the instrument fills its container.
223
+ */
224
+ @property({type: Number, attribute: 'face-diameter'})
225
+ faceDiameter: number | undefined;
215
226
  @property({type: Array, attribute: false}) areas: WatchArea[] = [];
216
227
  @property({type: Array, attribute: false}) barAreas: WatchBarArea[] = [];
217
228
  @property({type: Array, attribute: false}) needles: WatchNeedle[] = [];
@@ -250,17 +261,25 @@ export class ObcWatch extends LitElement {
250
261
  @property({type: Number}) scaleWindIcon: number = 1;
251
262
  @property({type: Number}) rotation: number | undefined;
252
263
  @property({type: Boolean}) zoomToFitArc: boolean = false;
264
+ /** Pre-computed zoom-to-fit arc frame. When set, the watch skips its own `computeZoomToFitArcFrame()` call and uses these values directly. Consumer instruments (e.g. rudder, instrument-radial) should compute the frame once and pass it here to avoid redundant computation. If you pass `arcFrame`, you own keeping it in sync with `areas` / `watchCircleType` — obc-watch will NOT recompute it, so a stale frame renders stale geometry. */
253
265
  @property({attribute: false}) arcFrame: ZoomToFitArcFrame | undefined;
254
266
  @property({type: Number}) tickFadeAngle: number = 0;
255
267
 
268
+ /** ROT visualization type: `'dots'` (spinning dots) or `'bar'` (arc bar with clipped dots). Undefined hides the ROT layer. */
256
269
  @property({type: String}) rotType: RotType | undefined;
270
+ /** Track on which ROT elements are placed: `'scale'` (on the outer ring) or `'innerCircle'` (default, inside the inner ring) */
257
271
  @property({type: String}) rotPosition: RotPosition = RotPosition.innerCircle;
272
+ /** Start angle of the ROT bar arc in degrees (0° = 12 o'clock, clockwise). Only used when `rotType` is `'bar'`. */
258
273
  @property({type: Number}) rotStartAngle: number = 0;
274
+ /** End angle of the ROT bar arc in degrees. The bar is hidden when the difference from `rotStartAngle` is less than 0.1°. */
259
275
  @property({type: Number}) rotEndAngle: number = 0;
276
+ /** Override priority for ROT color derivation. When set, ROT colors use this instead of the main `priority`. Useful when the ROT element has independent priority (e.g. compass per-element priority). */
260
277
  @property({type: String}) rotPriority: Priority | undefined;
261
278
  @property({type: Boolean}) rotPortStarboard: boolean = false;
262
279
  @property({type: Number}) rotAtZeroDeadband: number = ROT_ZERO_DEADBAND_DEG;
280
+ /** Measured rate of turn in degrees per minute (the maritime/AIS convention, see ES-TRIN 2025/1 Art. 3.02 and ITU-R M.1371). Sign controls direction (positive = starboard/clockwise). When defined, this drives both the dot animation (multiplied by `rotDotAnimationFactor`) and the port/starboard direction sign. */
263
281
  @property({type: Number}) rateOfTurnDegreesPerMinute: number | undefined;
282
+ /** Visual amplification factor applied only to the spinning-dot animation (not to bar extent). Default `18` keeps the legacy visual feel (≈1 rpm at 20°/min). */
264
283
  @property({type: Number}) rotDotAnimationFactor: number = 18;
265
284
  /**
266
285
  * @deprecated Use `rateOfTurnDegreesPerMinute` (and optionally `rotDotAnimationFactor`) instead.
@@ -271,6 +290,7 @@ export class ObcWatch extends LitElement {
271
290
  set rotationsPerMinute(value: number) {
272
291
  this._legacyRotationsPerMinute = value;
273
292
  }
293
+ /** Legacy spin speed of the ROT dot ring, in rotations per minute (sign controls direction; positive = clockwise). */
274
294
  get rotationsPerMinute() {
275
295
  return this._legacyRotationsPerMinute;
276
296
  }
@@ -293,10 +313,13 @@ export class ObcWatch extends LitElement {
293
313
  return this._legacyRotationsPerMinute;
294
314
  }
295
315
 
296
- // @ts-expect-error TS6133: The controller ensures that the render
297
- // function is called on resize of the element
298
316
  private _resizeController = new ResizeController(this, {});
299
317
 
318
+ override firstUpdated(changed: PropertyValues): void {
319
+ super.firstUpdated(changed);
320
+ observeInnerBox(this._resizeController, this.renderRoot);
321
+ }
322
+
300
323
  override willUpdate(changed: PropertyValues): void {
301
324
  super.willUpdate(changed);
302
325
 
@@ -333,6 +356,11 @@ export class ObcWatch extends LitElement {
333
356
 
334
357
  override updated(changed: PropertyValues): void {
335
358
  super.updated(changed);
359
+ this._hostSizePinned = applyPinnedHostSize(
360
+ this,
361
+ this.arcFrame ? undefined : this._ownFrame,
362
+ this._hostSizePinned
363
+ );
336
364
  const el = this.rotType
337
365
  ? this.renderRoot.querySelector('#rot-spinner')
338
366
  : null;
@@ -356,6 +384,18 @@ export class ObcWatch extends LitElement {
356
384
 
357
385
  private _rOff = 0;
358
386
 
387
+ /**
388
+ * Set by the frame when the label reserve exceeded its cap: tick label
389
+ * texts are dropped instead of clipped (see radial-frame.ts).
390
+ */
391
+ private _labelsHidden = false;
392
+
393
+ /** The frame of the last render, when computed internally (no arcFrame). */
394
+ private _ownFrame: RadialFrame | undefined;
395
+
396
+ /** Whether the host size styles were set by applyPinnedHostSize. */
397
+ private _hostSizePinned = false;
398
+
359
399
  /**
360
400
  * Radius for a dial-band edge under zoom: additive (`base + _rOff`), keeping
361
401
  * band thickness constant in SVG units. INVARIANT: every band-edge radius
@@ -680,16 +720,8 @@ export class ObcWatch extends LitElement {
680
720
  }
681
721
 
682
722
  private getScale({width, height}: {width: number; height: number}): number {
683
- let clientWidth = this.clientWidth;
684
- let clientHeight = this.clientHeight;
685
- if (clientWidth === 0 || clientHeight === 0) {
686
- const box = this.parentElement?.getBoundingClientRect();
687
- if (box) {
688
- clientWidth = box.width;
689
- clientHeight = box.height;
690
- }
691
- }
692
- const scale = Math.min(clientWidth / width, clientHeight / height);
723
+ const container = measureContainerPx(this);
724
+ const scale = Math.min(container.width / width, container.height / height);
693
725
  // On first paint the element and its parent can both still be zero-sized, so
694
726
  // the scale is 0 (or non-finite). That value flows into `px / scale` label
695
727
  // math and yields ±Infinity coordinates the browser rejects. Fall back to a
@@ -701,17 +733,19 @@ export class ObcWatch extends LitElement {
701
733
  return scale;
702
734
  }
703
735
 
704
- private getPadding(): number {
705
- if (this.padding !== undefined) {
706
- return this.padding;
736
+ /**
737
+ * Pixel width of the widest outside label, feeding the frame's label
738
+ * reserve (issue #1021). Explicit `padding` is a hard geometry override
739
+ * and disables the reserve, preserving legacy consumer output.
740
+ */
741
+ private getLabelWidthPx(): number {
742
+ if (this.padding !== undefined || this.tickmarksInside) {
743
+ return 0;
707
744
  }
708
- const hasTickmarksWithText =
709
- this.tickmarks.length > 0 &&
710
- this.tickmarks.some((t) => t.text !== undefined);
711
- if (hasTickmarksWithText && !this.tickmarksInside) {
712
- return 24 * 2.5;
745
+ if (this.showLabels) {
746
+ return NSWE_LABEL_WIDTH_PX;
713
747
  }
714
- return 24;
748
+ return estimateLabelWidthPx(this.tickmarks.map((t) => t.text));
715
749
  }
716
750
 
717
751
  override render() {
@@ -721,31 +755,33 @@ export class ObcWatch extends LitElement {
721
755
 
722
756
  if (this.arcFrame) {
723
757
  this._rOff = this.arcFrame.radiusOffset;
758
+ this._labelsHidden = false;
759
+ this._ownFrame = undefined;
724
760
  width = this.arcFrame.width;
725
761
  height = this.arcFrame.height;
726
762
  viewBox = this.arcFrame.viewBox;
727
- } else if (this.zoomToFitArc && this.areas.length > 0) {
728
- const ext = this.getPadding();
729
- const targetSize = (176 + ext) * 2;
730
- const frame = computeZoomToFitArcFrame({
763
+ } else {
764
+ const frame = computeRadialFrame({
765
+ basePadding: this.padding ?? 24,
766
+ labelWidthPx: this.getLabelWidthPx(),
767
+ clips: {
768
+ top: this.clipTop,
769
+ bottom: this.clipBottom,
770
+ left: this.clipLeft,
771
+ right: this.clipRight,
772
+ },
773
+ containerPx: measureContainerPx(this),
774
+ faceDiameter: this.faceDiameter,
775
+ zoomToFitArc: this.zoomToFitArc,
731
776
  areas: this.areas,
732
- outerRadius: OUTER_RING_RADIUS,
733
777
  innerRadius: this.innerRingRadius,
734
- extension: ext,
735
- targetSize,
736
778
  });
737
779
  this._rOff = frame.radiusOffset;
780
+ this._labelsHidden = frame.labelsHidden;
781
+ this._ownFrame = frame;
738
782
  width = frame.width;
739
783
  height = frame.height;
740
784
  viewBox = frame.viewBox;
741
- } else {
742
- this._rOff = 0;
743
- const full = (176 + this.getPadding()) * 2;
744
- width = full * (1 - this.clipLeft / 100 - this.clipRight / 100);
745
- height = full * (1 - this.clipTop / 100 - this.clipBottom / 100);
746
- const left = -full / 2 + (full * this.clipLeft) / 100;
747
- const top = -full / 2 + (full * this.clipTop) / 100;
748
- viewBox = `${left} ${top} ${width} ${height}`;
749
785
  }
750
786
 
751
787
  const rOff = this._rOff;
@@ -764,7 +800,7 @@ export class ObcWatch extends LitElement {
764
800
  size: t.type,
765
801
  style: this.tickmarkStyle,
766
802
  scale,
767
- text: this.showLabels ? undefined : t.text,
803
+ text: this.showLabels || this._labelsHidden ? undefined : t.text,
768
804
  inside: this.tickmarksInside,
769
805
  textRadius,
770
806
  rotation: this.rotation,
@@ -778,10 +814,14 @@ export class ObcWatch extends LitElement {
778
814
  ? this.advices.map((a) => renderAdvice(a, rOff))
779
815
  : nothing;
780
816
 
781
- // Compute label positions once – used for both rendering and crosshair knockout.
782
- const insideLabels = this.tickmarksInside && this.showLabels;
817
+ // Compute label positions once – used for both rendering and crosshair
818
+ // knockout. NSWE labels and the north arrow are px-fixed outside decor,
819
+ // so they follow the same labelsHidden degradation as tick label texts.
820
+ const showNsweLabels = this.showLabels && !this._labelsHidden;
821
+ const showNorthArrow = this.northArrow && !this._labelsHidden;
822
+ const insideLabels = this.tickmarksInside && showNsweLabels;
783
823
  const includeNorth = !this.northArrow;
784
- const labelPositions = this.showLabels
824
+ const labelPositions = showNsweLabels
785
825
  ? getLabelPositions({
786
826
  scale,
787
827
  inside: this.tickmarksInside,
@@ -799,7 +839,7 @@ export class ObcWatch extends LitElement {
799
839
  includeNorth,
800
840
  })
801
841
  : nothing;
802
- const northArrowEl = this.northArrow
842
+ const northArrowEl = showNorthArrow
803
843
  ? renderNorthArrow({
804
844
  scale,
805
845
  rotation: this.rotation,
@@ -98,6 +98,16 @@ export function widthDecorator(
98
98
  story: () => unknown,
99
99
  context: {args: {width?: number; height?: number}}
100
100
  ): HTMLTemplateResult {
101
+ // Stories that manage their own container (e.g. user-resizable sizing
102
+ // playgrounds) opt out with `parameters: {widthDecorator: false}` — the
103
+ // fixed-size overflow:auto wrapper would fence in their resize handle.
104
+ // (Read via a cast so the signature stays assignable to Storybook's
105
+ // DecoratorFunction for every Meta typing in the repo.)
106
+ const parameters = (context as {parameters?: {widthDecorator?: boolean}})
107
+ .parameters;
108
+ if (parameters?.widthDecorator === false) {
109
+ return html`${story()}`;
110
+ }
101
111
  const width = context.args.width ?? 300;
102
112
  const height = context.args.height ?? width;
103
113
  return html` <div
@@ -107,3 +117,52 @@ export function widthDecorator(
107
117
  ${story()}
108
118
  </div>`;
109
119
  }
120
+
121
+ /**
122
+ * User-resizable container for sizing-playground stories: drag the
123
+ * bottom-right corner and watch the content adapt. CSS `resize` only works
124
+ * with a non-visible `overflow`, hence `overflow: auto` on the box itself.
125
+ */
126
+ export function resizableStoryBox(
127
+ content: unknown,
128
+ {width = 560, height = 320}: {width?: number; height?: number} = {}
129
+ ): HTMLTemplateResult {
130
+ return html`<div
131
+ style="resize: both; overflow: auto; border: 1px dashed var(--instrument-frame-tertiary-color, gray); width: ${width}px; height: ${height}px; display: flex; gap: 8px; align-items: stretch;"
132
+ >
133
+ ${content}
134
+ </div>`;
135
+ }
136
+
137
+ /**
138
+ * One captioned column inside a sizing-playground flex row. A `pinned`
139
+ * instrument sets its own intrinsic size (faceDiameter), so its column
140
+ * shrink-wraps; an adaptive one gets an equal share of the remaining space.
141
+ */
142
+ export function playgroundColumn(
143
+ caption: string,
144
+ content: unknown,
145
+ {pinned = false}: {pinned?: boolean} = {}
146
+ ): HTMLTemplateResult {
147
+ return html`<div
148
+ style="flex: ${pinned
149
+ ? '0 0 auto'
150
+ : '1 1 0'}; min-width: 0; display: flex; flex-direction: column; gap: 2px;"
151
+ >
152
+ <span
153
+ style="flex: 0 0 auto; font-family: var(--font-family-main, sans-serif); font-size: 11px; color: var(--instrument-frame-secondary-color, gray); white-space: nowrap;"
154
+ >
155
+ ${caption}
156
+ </span>
157
+ <div style="flex: 1 1 0; min-height: 0;">${content}</div>
158
+ </div>`;
159
+ }
160
+
161
+ /** Short explanatory line rendered above a sizing-playground box. */
162
+ export function storyHint(text: string): HTMLTemplateResult {
163
+ return html`<p
164
+ style="max-width: 70ch; margin: 0 0 8px; font-family: var(--font-family-main, sans-serif); font-size: 12px; color: var(--instrument-frame-secondary-color, gray);"
165
+ >
166
+ ${text}
167
+ </p>`;
168
+ }