@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.102 → 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 (65) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +579 -143
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +338 -8
  4. package/dist/building-blocks/instrument-radial/instrument-radial.d.ts +22 -2
  5. package/dist/building-blocks/instrument-radial/instrument-radial.d.ts.map +1 -1
  6. package/dist/building-blocks/instrument-radial/instrument-radial.js +61 -37
  7. package/dist/building-blocks/instrument-radial/instrument-radial.js.map +1 -1
  8. package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.d.ts +14 -0
  9. package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.d.ts.map +1 -1
  10. package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.js +30 -11
  11. package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.js.map +1 -1
  12. package/dist/navigation-instruments/compass/compass.d.ts +21 -1
  13. package/dist/navigation-instruments/compass/compass.d.ts.map +1 -1
  14. package/dist/navigation-instruments/compass/compass.js +36 -16
  15. package/dist/navigation-instruments/compass/compass.js.map +1 -1
  16. package/dist/navigation-instruments/compass-sector/compass-sector.d.ts.map +1 -1
  17. package/dist/navigation-instruments/compass-sector/compass-sector.js.map +1 -1
  18. package/dist/navigation-instruments/gauge-radial/gauge-radial.css.js +34 -2
  19. package/dist/navigation-instruments/gauge-radial/gauge-radial.css.js.map +1 -1
  20. package/dist/navigation-instruments/gauge-radial/gauge-radial.d.ts +15 -0
  21. package/dist/navigation-instruments/gauge-radial/gauge-radial.d.ts.map +1 -1
  22. package/dist/navigation-instruments/gauge-radial/gauge-radial.js +35 -1
  23. package/dist/navigation-instruments/gauge-radial/gauge-radial.js.map +1 -1
  24. package/dist/navigation-instruments/heading/heading.d.ts +19 -1
  25. package/dist/navigation-instruments/heading/heading.d.ts.map +1 -1
  26. package/dist/navigation-instruments/heading/heading.js +34 -16
  27. package/dist/navigation-instruments/heading/heading.js.map +1 -1
  28. package/dist/navigation-instruments/rudder/rudder.d.ts +15 -2
  29. package/dist/navigation-instruments/rudder/rudder.d.ts.map +1 -1
  30. package/dist/navigation-instruments/rudder/rudder.js +36 -26
  31. package/dist/navigation-instruments/rudder/rudder.js.map +1 -1
  32. package/dist/navigation-instruments/speed-gauge/speed-gauge.d.ts +15 -1
  33. package/dist/navigation-instruments/speed-gauge/speed-gauge.d.ts.map +1 -1
  34. package/dist/navigation-instruments/speed-gauge/speed-gauge.js +31 -3
  35. package/dist/navigation-instruments/speed-gauge/speed-gauge.js.map +1 -1
  36. package/dist/navigation-instruments/watch/watch.d.ts +29 -1
  37. package/dist/navigation-instruments/watch/watch.d.ts.map +1 -1
  38. package/dist/navigation-instruments/watch/watch.js +52 -38
  39. package/dist/navigation-instruments/watch/watch.js.map +1 -1
  40. package/dist/svghelpers/radial-frame.d.ts +122 -0
  41. package/dist/svghelpers/radial-frame.d.ts.map +1 -0
  42. package/dist/svghelpers/radial-frame.js +259 -0
  43. package/dist/svghelpers/radial-frame.js.map +1 -0
  44. package/package.json +1 -1
  45. package/src/building-blocks/instrument-radial/instrument-radial.stories.ts +187 -6
  46. package/src/building-blocks/instrument-radial/instrument-radial.ts +98 -47
  47. package/src/navigation-instruments/azimuth-thruster/azimuth-thruster.stories.ts +78 -1
  48. package/src/navigation-instruments/azimuth-thruster/azimuth-thruster.ts +55 -11
  49. package/src/navigation-instruments/compass/compass.stories.ts +88 -1
  50. package/src/navigation-instruments/compass/compass.ts +52 -16
  51. package/src/navigation-instruments/compass-sector/compass-sector.ts +7 -0
  52. package/src/navigation-instruments/gauge-radial/gauge-radial.css +32 -2
  53. package/src/navigation-instruments/gauge-radial/gauge-radial.stories.ts +265 -1
  54. package/src/navigation-instruments/gauge-radial/gauge-radial.ts +59 -1
  55. package/src/navigation-instruments/heading/heading.stories.ts +92 -1
  56. package/src/navigation-instruments/heading/heading.ts +50 -16
  57. package/src/navigation-instruments/rudder/rudder.stories.ts +83 -1
  58. package/src/navigation-instruments/rudder/rudder.ts +61 -34
  59. package/src/navigation-instruments/speed-gauge/speed-gauge.stories.ts +94 -1
  60. package/src/navigation-instruments/speed-gauge/speed-gauge.ts +57 -4
  61. package/src/navigation-instruments/watch/watch.stories.ts +140 -1
  62. package/src/navigation-instruments/watch/watch.ts +84 -44
  63. package/src/storybook-util.ts +59 -0
  64. package/src/svghelpers/radial-frame.spec.ts +307 -0
  65. package/src/svghelpers/radial-frame.ts +508 -0
@@ -1,7 +1,13 @@
1
1
  import type {Meta, StoryObj} from '@storybook/web-components-vite';
2
2
  import {ObcRudder, ObcRudderVariant} from './rudder.js';
3
3
  import './rudder.js';
4
- import {widthDecorator} from '../../storybook-util.js';
4
+ import {html} from 'lit';
5
+ import {
6
+ playgroundColumn,
7
+ resizableStoryBox,
8
+ storyHint,
9
+ widthDecorator,
10
+ } from '../../storybook-util.js';
5
11
  import {TickmarkStyle} from '../watch/tickmark.js';
6
12
  import {InstrumentState, Priority} from '../types.js';
7
13
  const meta: Meta<typeof ObcRudder> = {
@@ -20,6 +26,11 @@ const meta: Meta<typeof ObcRudder> = {
20
26
  },
21
27
  argTypes: {
22
28
  width: {control: {type: 'range', min: 32, max: 1028, step: 1}},
29
+ faceDiameter: {
30
+ control: {type: 'range', min: 100, max: 600, step: 10},
31
+ description:
32
+ 'Pins the outer-ring diameter in px (fixed intrinsic size, equal circumference across instruments). Clear to return to fill-the-container sizing.',
33
+ },
23
34
  angle: {control: {type: 'range', min: -90, max: 90, step: 1}},
24
35
  maxAngle: {control: {type: 'range', min: 2, max: 90, step: 1}},
25
36
  setpoint: {control: {type: 'range', min: -90, max: 90, step: 1}},
@@ -72,3 +83,74 @@ export const ZoomedInNarrow: Story = {
72
83
  showLabels: true,
73
84
  },
74
85
  };
86
+
87
+ type SizingPlaygroundArgs = Partial<ObcRudder> & {
88
+ lockFaceDiameter?: boolean;
89
+ };
90
+
91
+ /**
92
+ * Interactive sizing playground: drag the dashed box's bottom-right corner to
93
+ * resize it. The first rudder is pinned to a fixed intrinsic size by the
94
+ * `faceDiameter` control (its box is wider than tall, matching the 40% top
95
+ * clip), the second adapts to the remaining flex space, and the third is a
96
+ * zoomed narrow arc (`zoomToFitArc`) showing that the reserve composes with
97
+ * zoom (issue #1021). Enable `lockFaceDiameter` to pin all three to the same
98
+ * circumference. Related: *Sizing Playground* stories under Building
99
+ * Blocks/Watch, Building Blocks/Instrument Radial and Instruments/Gauge
100
+ * Radial.
101
+ */
102
+ export const SizingPlayground: StoryObj<SizingPlaygroundArgs> = {
103
+ name: 'Sizing Playground — FaceDiameter + Resizable (Manual)',
104
+ tags: ['skip-test'],
105
+ parameters: {widthDecorator: false},
106
+ args: {
107
+ faceDiameter: 260,
108
+ lockFaceDiameter: false,
109
+ },
110
+ argTypes: {
111
+ lockFaceDiameter: {
112
+ control: 'boolean',
113
+ description:
114
+ 'Apply faceDiameter to every instance (equal circumference) instead of only the first.',
115
+ },
116
+ },
117
+ render: (args) => {
118
+ const instances = [
119
+ {label: 'half circle', maxAngle: 45, zoom: false},
120
+ {label: 'half circle', maxAngle: 45, zoom: false},
121
+ {label: 'zoomed ±20°', maxAngle: 20, zoom: true},
122
+ ];
123
+ const fd = (index: number) =>
124
+ index === 0 || args.lockFaceDiameter ? args.faceDiameter : undefined;
125
+ const caption = (index: number, label: string) =>
126
+ fd(index) !== undefined
127
+ ? `${label} — pinned ${fd(index)}px`
128
+ : `${label} — adaptive (flex)`;
129
+ return html`
130
+ ${storyHint(
131
+ 'Drag the bottom-right corner of the dashed box to resize it. The first rudder is pinned by the faceDiameter control; the second and the zoomed narrow arc adapt to the remaining flex space. Enable lockFaceDiameter to pin all three to the same circumference.'
132
+ )}
133
+ ${resizableStoryBox(
134
+ html`
135
+ ${instances.map((g, index) =>
136
+ playgroundColumn(
137
+ caption(index, g.label),
138
+ html`
139
+ <obc-rudder
140
+ .angle=${15}
141
+ .setpoint=${30}
142
+ .maxAngle=${g.maxAngle}
143
+ .zoomToFitArc=${g.zoom}
144
+ .showLabels=${true}
145
+ .faceDiameter=${fd(index)}
146
+ ></obc-rudder>
147
+ `,
148
+ {pinned: fd(index) !== undefined}
149
+ )
150
+ )}
151
+ `,
152
+ {width: 760, height: 280}
153
+ )}
154
+ `;
155
+ },
156
+ };
@@ -1,20 +1,21 @@
1
- import {LitElement, css, html, nothing, svg} from 'lit';
1
+ import {LitElement, PropertyValues, css, html, nothing, svg} from 'lit';
2
2
  import {property} from 'lit/decorators.js';
3
+ import {ResizeController} from '@lit-labs/observers/resize-controller.js';
3
4
  import '../watch/watch.js';
4
5
  import {Tickmark, TickmarkStyle, TickmarkType} from '../watch/tickmark.js';
5
- import {
6
- OUTER_RING_RADIUS,
7
- WatchCircleType,
8
- innerRingRadiusFor,
9
- } from '../watch/watch.js';
6
+ import {WatchCircleType, innerRingRadiusFor} from '../watch/watch.js';
10
7
  import {InstrumentState, Priority} from '../types.js';
11
8
  import {SetpointMixin} from '../../svghelpers/setpoint-mixin.js';
12
9
  import {AdviceState, AngleAdvice, AngleAdviceRaw} from '../watch/advice.js';
13
10
  import {customElement} from '../../decorator.js';
14
11
  import {
15
- computeZoomToFitArcFrame,
16
- type ZoomToFitArcFrame,
17
- } from '../../svghelpers/arc-frame.js';
12
+ applyPinnedHostSize,
13
+ computeRadialFrame,
14
+ estimateLabelWidthPx,
15
+ measureContainerPx,
16
+ observeInnerBox,
17
+ type RadialFrame,
18
+ } from '../../svghelpers/radial-frame.js';
18
19
 
19
20
  export enum ObcRudderVariant {
20
21
  Bar = 'bar',
@@ -95,9 +96,37 @@ export class ObcRudder extends SetpointMixin(LitElement) {
95
96
  TickmarkStyle.regular;
96
97
  @property({type: Array, attribute: false}) advices: AngleAdvice[] = [];
97
98
  @property({type: Boolean}) zoomToFitArc: boolean = false;
99
+ /**
100
+ * Outer-ring diameter in CSS pixels. When set, the instrument renders at a
101
+ * fixed intrinsic size derived from the ring, arc shape and label reserve —
102
+ * so instruments sharing the same value have identical ring circumference
103
+ * regardless of label width or arc extent (like obc-donut-chart's
104
+ * fixedHeight). When unset (default), the instrument fills its container.
105
+ */
106
+ @property({type: Number, attribute: 'face-diameter'})
107
+ faceDiameter: number | undefined;
98
108
 
99
109
  private _radiusOffset = 0;
100
- private _arcFrame: ZoomToFitArcFrame | undefined;
110
+ private _frame: RadialFrame | undefined;
111
+
112
+ /** Whether the host size styles were set by applyPinnedHostSize. */
113
+ private _hostSizePinned = false;
114
+
115
+ private _resizeController = new ResizeController(this, {});
116
+
117
+ override firstUpdated(changed: PropertyValues): void {
118
+ super.firstUpdated(changed);
119
+ observeInnerBox(this._resizeController, this.renderRoot);
120
+ }
121
+
122
+ override updated(changed: PropertyValues): void {
123
+ super.updated(changed);
124
+ this._hostSizePinned = applyPinnedHostSize(
125
+ this,
126
+ this._frame,
127
+ this._hostSizePinned
128
+ );
129
+ }
101
130
 
102
131
  private get _needleTransform(): string {
103
132
  const rOff = this._radiusOffset;
@@ -243,33 +272,32 @@ export class ObcRudder extends SetpointMixin(LitElement) {
243
272
  };
244
273
  });
245
274
 
246
- let overlayViewBox: string;
247
- if (this.zoomToFitArc) {
248
- const ext = 48;
249
- const targetSize = (176 + ext) * 2;
250
- const frame = computeZoomToFitArcFrame({
251
- areas,
252
- outerRadius: OUTER_RING_RADIUS,
253
- innerRadius: innerRingRadiusFor(WatchCircleType.double),
254
- extension: ext,
255
- targetSize,
256
- });
257
- overlayViewBox = frame.viewBox;
258
- this._radiusOffset = frame.radiusOffset;
259
- this._arcFrame = frame;
260
- } else {
261
- overlayViewBox = '-224 -44.8 448 268.8';
262
- this._radiusOffset = 0;
263
- this._arcFrame = undefined;
264
- }
275
+ const frame = computeRadialFrame({
276
+ basePadding: 48,
277
+ labelWidthPx: this.tickmarksInside
278
+ ? 0
279
+ : estimateLabelWidthPx(tickmarks.map((t) => t.text)),
280
+ clips: this.zoomToFitArc
281
+ ? undefined
282
+ : {top: 40, bottom: 0, left: 0, right: 0},
283
+ containerPx: measureContainerPx(this),
284
+ faceDiameter: this.faceDiameter,
285
+ zoomToFitArc: this.zoomToFitArc,
286
+ areas,
287
+ innerRadius: innerRingRadiusFor(WatchCircleType.double),
288
+ });
289
+ this._radiusOffset = frame.radiusOffset;
290
+ this._frame = frame;
291
+ const shownTickmarks = frame.labelsHidden
292
+ ? tickmarks.map((t) => ({...t, text: undefined}))
293
+ : tickmarks;
294
+ const overlayViewBox = frame.viewBox;
265
295
 
266
296
  return html`
267
297
  <div class="container">
268
298
  <obc-watch
269
299
  .touching=${this.touching}
270
- .clipTop=${this.zoomToFitArc ? 0 : 40}
271
- .zoomToFitArc=${this.zoomToFitArc}
272
- .arcFrame=${this._arcFrame}
300
+ .arcFrame=${frame}
273
301
  .areas=${areas}
274
302
  .angleSetpoint=${setpointAngle}
275
303
  .newAngleSetpoint=${this.newSetpoint !== undefined
@@ -279,8 +307,7 @@ export class ObcRudder extends SetpointMixin(LitElement) {
279
307
  .angleSetpointAtZeroDeadband=${this.setpointAtZeroDeadband}
280
308
  .setpointOverride=${this.setpointOverride}
281
309
  .animateSetpoint=${this.animateSetpoint}
282
- .padding=${48}
283
- .tickmarks=${tickmarks}
310
+ .tickmarks=${shownTickmarks}
284
311
  .tickmarksInside=${this.tickmarksInside}
285
312
  .tickmarkStyle=${this.tickmarkStyle}
286
313
  .watchCircleType=${WatchCircleType.double}
@@ -1,7 +1,13 @@
1
1
  import type {Meta, StoryObj} from '@storybook/web-components-vite';
2
2
  import {ObcSpeedGauge, ObcSpeedGaugeNeedleType} from './speed-gauge.js';
3
3
  import './speed-gauge.js';
4
- import {widthDecorator} from '../../storybook-util.js';
4
+ import {html} from 'lit';
5
+ import {
6
+ playgroundColumn,
7
+ resizableStoryBox,
8
+ storyHint,
9
+ widthDecorator,
10
+ } from '../../storybook-util.js';
5
11
  import {AdviceType} from '../watch/advice.js';
6
12
  import {TickmarkStyle} from '../watch/tickmark.js';
7
13
  import {Priority} from '../types.js';
@@ -33,6 +39,11 @@ const meta: Meta<typeof ObcSpeedGauge> = {
33
39
  options: Object.values(TickmarkStyle),
34
40
  },
35
41
  hasReadout: {control: 'boolean'},
42
+ faceDiameter: {
43
+ control: {type: 'range', min: 100, max: 600, step: 10},
44
+ description:
45
+ 'Pins the outer-ring diameter in px (fixed intrinsic size, equal circumference across instruments). Clear to return to fill-the-container sizing.',
46
+ },
36
47
  label: {control: 'text'},
37
48
  unit: {control: 'text'},
38
49
  fractionDigits: {control: 'number'},
@@ -73,3 +84,85 @@ export const Readout: Story = {
73
84
  hasReadout: true,
74
85
  },
75
86
  };
87
+
88
+ export const LabelRoomHighSpeed: Story = {
89
+ name: 'Label Room — 4-Digit Scale in A Short Cell (#1021)',
90
+ args: {
91
+ minSpeed: 0,
92
+ maxSpeed: 3600,
93
+ speed: 2400,
94
+ tickmarkInterval: 900,
95
+ showLabels: true,
96
+ hasReadout: true,
97
+ width: 600,
98
+ height: 220,
99
+ } as never,
100
+ };
101
+
102
+ type SizingPlaygroundArgs = Partial<ObcSpeedGauge> & {
103
+ lockFaceDiameter?: boolean;
104
+ };
105
+
106
+ /**
107
+ * Interactive sizing playground: drag the dashed box's bottom-right corner to
108
+ * resize it. The first gauge is pinned to a fixed intrinsic size by the
109
+ * `faceDiameter` control, while the second adapts to the remaining flex
110
+ * space, reserving room for its 4-digit labels adaptively (issue #1021).
111
+ * Enable `lockFaceDiameter` to pin both to the same circumference regardless
112
+ * of their label widths. Related: *Sizing Playground* stories under Building
113
+ * Blocks/Watch, Building Blocks/Instrument Radial and Instruments/Gauge
114
+ * Radial.
115
+ */
116
+ export const SizingPlayground: StoryObj<SizingPlaygroundArgs> = {
117
+ name: 'Sizing Playground — FaceDiameter + Resizable (Manual)',
118
+ tags: ['skip-test'],
119
+ parameters: {widthDecorator: false},
120
+ args: {
121
+ faceDiameter: 220,
122
+ lockFaceDiameter: false,
123
+ },
124
+ argTypes: {
125
+ lockFaceDiameter: {
126
+ control: 'boolean',
127
+ description:
128
+ 'Apply faceDiameter to every instance (equal circumference) instead of only the first.',
129
+ },
130
+ },
131
+ render: (args) => {
132
+ const instances = [
133
+ {label: '2-digit scale', max: 40, interval: 10},
134
+ {label: '4-digit scale', max: 3600, interval: 900},
135
+ ];
136
+ const fd = (index: number) =>
137
+ index === 0 || args.lockFaceDiameter ? args.faceDiameter : undefined;
138
+ const caption = (index: number, label: string) =>
139
+ fd(index) !== undefined
140
+ ? `${label} — pinned ${fd(index)}px`
141
+ : `${label} — adaptive (flex)`;
142
+ return html`
143
+ ${storyHint(
144
+ 'Drag the bottom-right corner of the dashed box to resize it. The first gauge is pinned by the faceDiameter control; the second adapts to the remaining flex space. Enable lockFaceDiameter to pin both to the same circumference.'
145
+ )}
146
+ ${resizableStoryBox(
147
+ html`
148
+ ${instances.map((g, index) =>
149
+ playgroundColumn(
150
+ caption(index, g.label),
151
+ html`
152
+ <obc-speed-gauge
153
+ .speed=${g.max * 0.6}
154
+ .maxSpeed=${g.max}
155
+ .tickmarkInterval=${g.interval}
156
+ .showLabels=${true}
157
+ .faceDiameter=${fd(index)}
158
+ ></obc-speed-gauge>
159
+ `,
160
+ {pinned: fd(index) !== undefined}
161
+ )
162
+ )}
163
+ `,
164
+ {width: 680, height: 340}
165
+ )}
166
+ `;
167
+ },
168
+ };
@@ -1,5 +1,14 @@
1
- import {LitElement, css, html, nothing, svg} from 'lit';
1
+ import {LitElement, PropertyValues, css, html, nothing, svg} from 'lit';
2
2
  import {property} from 'lit/decorators.js';
3
+ import {ResizeController} from '@lit-labs/observers/resize-controller.js';
4
+ import {
5
+ applyPinnedHostSize,
6
+ computeRadialFrame,
7
+ estimateLabelWidthPx,
8
+ measureContainerPx,
9
+ observeInnerBox,
10
+ type RadialFrame,
11
+ } from '../../svghelpers/radial-frame.js';
3
12
  import {Tickmark, TickmarkStyle, TickmarkType} from '../watch/tickmark.js';
4
13
  import {WatchCircleType} from '../watch/watch.js';
5
14
  import {AdviceType, AngleAdviceRaw, AdviceState} from '../watch/advice.js';
@@ -113,6 +122,36 @@ export class ObcSpeedGauge extends SetpointMixin(LitElement) {
113
122
  @property({type: String}) unit = 'KN';
114
123
  /** Number of fraction digits shown in the readout. Default `1`. */
115
124
  @property({type: Number}) fractionDigits = 1;
125
+ /**
126
+ * Outer-ring diameter in CSS pixels. When set, the instrument renders at a
127
+ * fixed intrinsic size derived from the ring, arc shape and label reserve —
128
+ * so instruments sharing the same value have identical ring circumference
129
+ * regardless of label width or arc extent (like obc-donut-chart's
130
+ * fixedHeight). When unset (default), the instrument fills its container.
131
+ */
132
+ @property({type: Number, attribute: 'face-diameter'})
133
+ faceDiameter: number | undefined;
134
+
135
+ private _frame: RadialFrame | undefined;
136
+
137
+ /** Whether the host size styles were set by applyPinnedHostSize. */
138
+ private _hostSizePinned = false;
139
+
140
+ private _resizeController = new ResizeController(this, {});
141
+
142
+ override firstUpdated(changed: PropertyValues): void {
143
+ super.firstUpdated(changed);
144
+ observeInnerBox(this._resizeController, this.renderRoot);
145
+ }
146
+
147
+ override updated(changed: PropertyValues): void {
148
+ super.updated(changed);
149
+ this._hostSizePinned = applyPinnedHostSize(
150
+ this,
151
+ this._frame,
152
+ this._hostSizePinned
153
+ );
154
+ }
116
155
 
117
156
  getAngle(v: number): number {
118
157
  return (v / this.maxSpeed) * (180 + 45) - 90;
@@ -134,6 +173,20 @@ export class ObcSpeedGauge extends SetpointMixin(LitElement) {
134
173
 
135
174
  const maxDigits = 1;
136
175
 
176
+ const tickmarks = this.tickmarks;
177
+ const frame = computeRadialFrame({
178
+ basePadding: 48,
179
+ labelWidthPx: this.tickmarksInside
180
+ ? 0
181
+ : estimateLabelWidthPx(tickmarks.map((t) => t.text)),
182
+ containerPx: measureContainerPx(this),
183
+ faceDiameter: this.faceDiameter,
184
+ });
185
+ this._frame = frame;
186
+ const shownTickmarks = frame.labelsHidden
187
+ ? tickmarks.map((t) => ({...t, text: undefined}))
188
+ : tickmarks;
189
+
137
190
  return html`
138
191
  <div class="container">
139
192
  <obc-watch
@@ -146,8 +199,8 @@ export class ObcSpeedGauge extends SetpointMixin(LitElement) {
146
199
  .angleSetpointAtZeroDeadband=${this.setpointAtZeroDeadband}
147
200
  .setpointOverride=${this.setpointOverride}
148
201
  .animateSetpoint=${this.animateSetpoint}
149
- .padding=${48}
150
- .tickmarks=${this.tickmarks}
202
+ .arcFrame=${frame}
203
+ .tickmarks=${shownTickmarks}
151
204
  .tickmarksInside=${this.tickmarksInside}
152
205
  .tickmarkStyle=${this.tickmarkStyle}
153
206
  .advices=${this._advices}
@@ -168,7 +221,7 @@ export class ObcSpeedGauge extends SetpointMixin(LitElement) {
168
221
  },
169
222
  ]}
170
223
  ></obc-watch>
171
- <svg class="rudder" viewBox="-224 -224 448 448">${this.needle}</svg>
224
+ <svg class="rudder" viewBox=${frame.viewBox}>${this.needle}</svg>
172
225
  ${this.hasReadout
173
226
  ? html`
174
227
  ${renderInstrumentReadout({
@@ -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
  *