@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
@@ -4,6 +4,13 @@
4
4
  height: 100%;
5
5
  }
6
6
 
7
+ /* With a pinned face the host shrink-wraps the fixed-size dial, so rows of
8
+ pinned instruments can sit side by side in flex layouts. */
9
+ :host([face-diameter]) {
10
+ width: fit-content;
11
+ height: fit-content;
12
+ }
13
+
7
14
  .gauge-radial-root {
8
15
  /* Readout vertical placement per layout (% of the cropped box), tuned to the
9
16
  watch geometry — re-check if the ring radii or the 448 box change. Which
@@ -24,7 +31,10 @@
24
31
  height: auto;
25
32
  max-width: 100%;
26
33
  max-height: 100%;
27
- aspect-ratio: 1;
34
+ /* --gauge-radial-aspect is set from TS only when the frame lowered a crop
35
+ for the label drop (frame.clipsAdjusted); otherwise the static sector
36
+ aspect below applies. */
37
+ aspect-ratio: var(--gauge-radial-aspect, 1);
28
38
 
29
39
  /* Defaults (centered both ways); overridden by the halign-* / valign-*
30
40
  classes. Horizontal alignment uses auto margins (which reference the host
@@ -38,7 +48,7 @@
38
48
  /* 448 / 251 = the 180° crop's aspect (448 wide × 448·(1 − 44%) tall); keep in
39
49
  sync with `sectorClips` bottom=44 in gauge-radial.ts. */
40
50
  &.sector-180 {
41
- aspect-ratio: 448 / 251;
51
+ aspect-ratio: var(--gauge-radial-aspect, 448 / 251);
42
52
  }
43
53
 
44
54
  &.halign-left {
@@ -67,6 +77,26 @@
67
77
  transform: translateY(-100%);
68
78
  }
69
79
 
80
+ /* faceDiameter pins the dial to a fixed intrinsic size: the inner
81
+ instrument sets its own px box and the root shrink-wraps it. The host
82
+ shrink-wraps too, so there is no slack to align within — neutralize the
83
+ alignment offsets (declared after the valign rules to win the cascade;
84
+ `top: 50%` would resolve to 0 in the auto-height host anyway while the
85
+ translateY still applied, shifting the dial up by half its height). */
86
+ &.face-pinned {
87
+ width: fit-content;
88
+ height: fit-content;
89
+ max-width: none;
90
+ max-height: none;
91
+ aspect-ratio: auto;
92
+ top: 0;
93
+ transform: none;
94
+
95
+ &.sector-180 {
96
+ aspect-ratio: auto;
97
+ }
98
+ }
99
+
70
100
  .gauge-readout-value {
71
101
  position: absolute;
72
102
  left: 50%;
@@ -1,4 +1,5 @@
1
1
  import type {Meta, StoryObj} from '@storybook/web-components-vite';
2
+ import {html} from 'lit';
2
3
  import {
3
4
  GaugeRadialHorizontalAlignment,
4
5
  GaugeRadialSector,
@@ -7,7 +8,12 @@ import {
7
8
  ObcGaugeRadialType,
8
9
  } from './gauge-radial.js';
9
10
  import './gauge-radial.js';
10
- import {widthDecorator} from '../../storybook-util.js';
11
+ import {
12
+ playgroundColumn,
13
+ resizableStoryBox,
14
+ storyHint,
15
+ widthDecorator,
16
+ } from '../../storybook-util.js';
11
17
  import {AdviceType} from '../watch/advice.js';
12
18
  import {TickmarkStyle} from '../watch/tickmark.js';
13
19
  import {InstrumentState, Priority} from '../types.js';
@@ -15,6 +21,8 @@ import {InstrumentState, Priority} from '../types.js';
15
21
  type GaugeRadialStoryArgs = Partial<ObcGaugeRadial> & {
16
22
  width?: number;
17
23
  height?: number;
24
+ /** Story-only: apply faceDiameter to every playground instance. */
25
+ lockFaceDiameter?: boolean;
18
26
  };
19
27
 
20
28
  const meta = {
@@ -47,6 +55,11 @@ const meta = {
47
55
  showLabels: {control: 'boolean'},
48
56
  tickmarksInside: {control: 'boolean'},
49
57
  hasReadout: {control: 'boolean'},
58
+ faceDiameter: {
59
+ control: {type: 'range', min: 100, max: 600, step: 10},
60
+ description:
61
+ 'Pins the outer-ring diameter in px (fixed intrinsic size, equal circumference across instruments). Clear to return to fill-the-container sizing.',
62
+ },
50
63
  value: {control: 'number'},
51
64
  minValue: {control: 'number'},
52
65
  maxValue: {control: 'number'},
@@ -357,3 +370,254 @@ export const WideAdviceSpan: Story = {
357
370
  ],
358
371
  },
359
372
  };
373
+
374
+ const labelRoomRow = html`
375
+ <div style="display: flex; gap: 8px; width: 100%; height: 100%;">
376
+ ${[
377
+ {label: 'Load', max: 100},
378
+ {label: 'Torque', max: 100},
379
+ {label: 'Throttle', max: 100},
380
+ {label: 'RPM', max: 3600},
381
+ ].map(
382
+ (g) => html`
383
+ <div style="flex: 1; min-width: 0;">
384
+ <obc-gauge-radial
385
+ .value=${g.max * 0.6}
386
+ .maxValue=${g.max}
387
+ .showLabels=${true}
388
+ .primaryTickmarkInterval=${g.max / 4}
389
+ .secondaryTickmarkInterval=${g.max / 20}
390
+ .hasReadout=${true}
391
+ .label=${g.label}
392
+ ></obc-gauge-radial>
393
+ </div>
394
+ `
395
+ )}
396
+ </div>
397
+ `;
398
+
399
+ /**
400
+ * The issue #1021 repro: four gauges squeezed in a flex dashboard row. Each
401
+ * dial automatically reserves room for its own labels, so the 4-digit RPM
402
+ * scale gets a wider margin (and a slightly smaller ring) than its 2-digit
403
+ * siblings — nothing is clipped. To keep the rings the same size instead,
404
+ * give every gauge the same `faceDiameter` — see the *Equal Circumference*
405
+ * stories. Try the *Sizing Playground* story to explore interactively.
406
+ */
407
+ export const LabelRoomFourDigitRow: Story = {
408
+ name: 'Label Room — Mixed 2/4-Digit Flex Row (#1021)',
409
+ args: {
410
+ width: 800,
411
+ height: 200,
412
+ },
413
+ render: () => labelRoomRow,
414
+ };
415
+
416
+ /**
417
+ * The same row squeezed below the label-reserve cap
418
+ * (`LABEL_RESERVE_MAX_FRACTION` in `svghelpers/radial-frame.ts`): when labels
419
+ * would eat more than ~45% of the box, they are hidden entirely instead of
420
+ * clipped — matching the canvas charts' graceful degradation. The threshold
421
+ * is content-aware: 2-digit labels survive smaller cells than 4-digit ones.
422
+ */
423
+ export const LabelRoomDegradationRow: Story = {
424
+ name: 'Label Room — Hide Past Reserve Cap (#1021)',
425
+ args: {
426
+ width: 800,
427
+ height: 140,
428
+ },
429
+ render: () => labelRoomRow,
430
+ };
431
+
432
+ /**
433
+ * `faceDiameter` pins the outer-ring diameter in px, so all four sectors
434
+ * (270°, 180°, 90°-left, 90°-right) render with identical ring circumference
435
+ * while their boxes differ per arc shape — the radial counterpart of
436
+ * `obc-donut-chart`'s fixed-height ⇒ fixed-circumference contract. Use the
437
+ * `faceDiameter` control to scale all of them together.
438
+ */
439
+ export const EqualCircumferenceMixedArcs: Story = {
440
+ name: 'Equal Circumference — Same FaceDiameter Across Sectors',
441
+ args: {
442
+ width: 900,
443
+ height: 320,
444
+ faceDiameter: 180,
445
+ },
446
+ render: (args) => html`
447
+ <div style="display: flex; gap: 8px; align-items: flex-start;">
448
+ ${[
449
+ GaugeRadialSector.deg270,
450
+ GaugeRadialSector.deg180,
451
+ GaugeRadialSector.deg90Left,
452
+ GaugeRadialSector.deg90Right,
453
+ ].map(
454
+ (sector) => html`
455
+ <obc-gauge-radial
456
+ .sector=${sector}
457
+ .value=${60}
458
+ .maxValue=${100}
459
+ .faceDiameter=${args.faceDiameter ?? 180}
460
+ .showLabels=${true}
461
+ .primaryTickmarkInterval=${25}
462
+ .secondaryTickmarkInterval=${5}
463
+ ></obc-gauge-radial>
464
+ `
465
+ )}
466
+ </div>
467
+ `,
468
+ };
469
+
470
+ /**
471
+ * Two gauges with the same `faceDiameter` but different label widths: the
472
+ * rings are pixel-identical while the 4-digit gauge's box grows wider to hold
473
+ * its labels — the "equal circumference" answer to the unequal rings visible
474
+ * in the *Label Room* stories. Use the `faceDiameter` control to resize both.
475
+ */
476
+ export const EqualCircumferenceMixedDigits: Story = {
477
+ name: 'Equal Circumference — 2-Digit Vs 4-Digit, Same FaceDiameter',
478
+ args: {
479
+ width: 700,
480
+ height: 300,
481
+ faceDiameter: 200,
482
+ },
483
+ render: (args) => html`
484
+ <div style="display: flex; gap: 8px; align-items: center;">
485
+ ${[
486
+ {label: 'Load', max: 99, interval: 33},
487
+ {label: 'RPM', max: 3600, interval: 900},
488
+ ].map(
489
+ (g) => html`
490
+ <obc-gauge-radial
491
+ .value=${g.max * 0.6}
492
+ .maxValue=${g.max}
493
+ .faceDiameter=${args.faceDiameter ?? 200}
494
+ .showLabels=${true}
495
+ .primaryTickmarkInterval=${g.interval}
496
+ .hasReadout=${true}
497
+ .label=${g.label}
498
+ ></obc-gauge-radial>
499
+ `
500
+ )}
501
+ </div>
502
+ `,
503
+ };
504
+
505
+ /**
506
+ * Interactive sizing playground: drag the dashed box's bottom-right corner to
507
+ * resize it. The first gauge is pinned to a fixed intrinsic size by the
508
+ * `faceDiameter` control (donut-chart `fixedHeight` behavior), while the
509
+ * 4-digit gauge and the 180° sector adapt to the remaining flex space,
510
+ * reserving label room adaptively (the *Label Room* stories). Enable
511
+ * `lockFaceDiameter` to pin all three to the same circumference — full circle
512
+ * and partial arcs alike (the *Equal Circumference* stories). The same
513
+ * behavior is available on every radial instrument — see the *Sizing
514
+ * Playground* stories under Building Blocks/Watch, Building Blocks/Instrument
515
+ * Radial, Instruments/Speed Gauge, Instruments/Compass, etc.
516
+ */
517
+ export const SizingPlayground: Story = {
518
+ name: 'Sizing Playground — FaceDiameter + Resizable (Manual)',
519
+ tags: ['skip-test'],
520
+ parameters: {widthDecorator: false},
521
+ args: {
522
+ faceDiameter: 180,
523
+ lockFaceDiameter: false,
524
+ },
525
+ argTypes: {
526
+ lockFaceDiameter: {
527
+ control: 'boolean',
528
+ description:
529
+ 'Apply faceDiameter to every instance (equal circumference) instead of only the first.',
530
+ },
531
+ },
532
+ render: (args) => {
533
+ const instances = [
534
+ {
535
+ label: '2-digit, 270°',
536
+ sector: GaugeRadialSector.deg270,
537
+ max: 100,
538
+ interval: 25,
539
+ },
540
+ {
541
+ label: '4-digit, 270°',
542
+ sector: GaugeRadialSector.deg270,
543
+ max: 3600,
544
+ interval: 900,
545
+ },
546
+ {
547
+ label: '2-digit, 180° sector',
548
+ sector: GaugeRadialSector.deg180,
549
+ max: 100,
550
+ interval: 25,
551
+ },
552
+ ];
553
+ const fd = (index: number) =>
554
+ index === 0 || args.lockFaceDiameter ? args.faceDiameter : undefined;
555
+ const caption = (index: number, label: string) =>
556
+ fd(index) !== undefined
557
+ ? `${label} — pinned ${fd(index)}px`
558
+ : `${label} — adaptive (flex)`;
559
+ return html`
560
+ ${storyHint(
561
+ 'Drag the bottom-right corner of the dashed box to resize it. The first gauge is pinned by the faceDiameter control; the 4-digit gauge and the 180° sector adapt to the remaining flex space. Enable lockFaceDiameter to pin all three to the same circumference.'
562
+ )}
563
+ ${resizableStoryBox(
564
+ html`
565
+ ${instances.map((g, index) =>
566
+ playgroundColumn(
567
+ caption(index, g.label),
568
+ html`
569
+ <obc-gauge-radial
570
+ .sector=${g.sector}
571
+ .value=${g.max * 0.6}
572
+ .maxValue=${g.max}
573
+ .faceDiameter=${fd(index)}
574
+ .showLabels=${true}
575
+ .primaryTickmarkInterval=${g.interval}
576
+ ></obc-gauge-radial>
577
+ `,
578
+ {pinned: fd(index) !== undefined}
579
+ )
580
+ )}
581
+ `,
582
+ {width: 760, height: 300}
583
+ )}
584
+ `;
585
+ },
586
+ };
587
+
588
+ /**
589
+ * A 4-digit scale in a narrow column: the reserve is driven by the binding
590
+ * axis (here the width), so the labels stay readable while the ring shrinks.
591
+ */
592
+ export const LabelRoomTallNarrow: Story = {
593
+ name: 'Label Room — Tall Narrow 4-Digit (#1021)',
594
+ args: {
595
+ value: 2400,
596
+ minValue: 0,
597
+ maxValue: 3600,
598
+ showLabels: true,
599
+ primaryTickmarkInterval: 900,
600
+ secondaryTickmarkInterval: 300,
601
+ width: 160,
602
+ height: 400,
603
+ },
604
+ };
605
+
606
+ /**
607
+ * With `tickmarksInside` the labels live inside the ring, so no outside
608
+ * reserve is needed — the dial keeps the full legacy box at any label width.
609
+ */
610
+ export const LabelRoomInsideLabels: Story = {
611
+ name: 'Label Room — Inside Labels Keep The Base Box (#1021)',
612
+ args: {
613
+ value: 2400,
614
+ minValue: 0,
615
+ maxValue: 3600,
616
+ showLabels: true,
617
+ tickmarksInside: true,
618
+ primaryTickmarkInterval: 900,
619
+ secondaryTickmarkInterval: 300,
620
+ width: 200,
621
+ height: 200,
622
+ },
623
+ };
@@ -2,7 +2,8 @@ import {LitElement, html, nothing, unsafeCSS, type TemplateResult} from 'lit';
2
2
  import {classMap} from 'lit/directives/class-map.js';
3
3
  import componentStyle from './gauge-radial.css?inline';
4
4
  import {customElement} from '../../decorator.js';
5
- import {property} from 'lit/decorators.js';
5
+ import {property, query} from 'lit/decorators.js';
6
+ import type {RadialFrame} from '../../svghelpers/radial-frame.js';
6
7
  import {AdviceType} from '../watch/advice.js';
7
8
  import {InstrumentState, Priority} from '../types.js';
8
9
  import {SetpointMixin} from '../../svghelpers/setpoint-mixin.js';
@@ -43,6 +44,17 @@ export interface GaugeRadialAdvice {
43
44
  hinted: boolean;
44
45
  }
45
46
 
47
+ /**
48
+ * Readout meta-row anchors in SVG units, derived from the legacy CSS
49
+ * percentages against the default 448 box (72% → 98.56, 63% → 58.24; the 180°
50
+ * sector's 60% of its 250.88-high crop → −73.472). Converted back to
51
+ * percentages against the actual frame so readouts stay put when the box
52
+ * grows for the label reserve (issue #1021).
53
+ */
54
+ const READOUT_META_Y = 98.56;
55
+ const READOUT_META_Y_NEEDLE = 58.24;
56
+ const READOUT_META_Y_180 = -73.472;
57
+
46
58
  /**
47
59
  * `<obc-gauge-radial>` — Configurable radial gauge for generic numeric values.
48
60
  *
@@ -165,6 +177,15 @@ export class ObcGaugeRadial extends SetpointMixin(LitElement) {
165
177
  @property({type: String}) label = '';
166
178
  @property({type: String}) unit = '';
167
179
  @property({type: Number}) fractionDigits = 0;
180
+ /**
181
+ * Outer-ring diameter in CSS pixels. When set, the instrument renders at a
182
+ * fixed intrinsic size derived from the ring, arc shape and label reserve —
183
+ * so instruments sharing the same value have identical ring circumference
184
+ * regardless of label width or arc extent (like obc-donut-chart's
185
+ * fixedHeight). When unset (default), the instrument fills its container.
186
+ */
187
+ @property({type: Number, attribute: 'face-diameter', reflect: true})
188
+ faceDiameter: number | undefined;
168
189
 
169
190
  private get sectorAngles(): {sweep: number; start: number} {
170
191
  switch (this.sector) {
@@ -212,6 +233,40 @@ export class ObcGaugeRadial extends SetpointMixin(LitElement) {
212
233
  );
213
234
  }
214
235
 
236
+ /** @internal */
237
+ @query('.gauge-radial-root') private _rootEl?: HTMLElement;
238
+
239
+ /**
240
+ * Re-anchor the %-positioned readouts against the frame the inner
241
+ * instrument actually rendered (the box grows when labels need room).
242
+ */
243
+ private _onFrameChanged = (e: Event): void => {
244
+ const frame = (e as CustomEvent<RadialFrame>).detail;
245
+ const root = this._rootEl;
246
+ if (!root || !(frame.height > 0)) {
247
+ return;
248
+ }
249
+ const pct = (anchorY: number) =>
250
+ `${(((anchorY - frame.y) / frame.height) * 100).toFixed(4)}%`;
251
+ root.style.setProperty('--readout-meta-top', pct(READOUT_META_Y));
252
+ root.style.setProperty(
253
+ '--readout-meta-top-needle',
254
+ pct(READOUT_META_Y_NEEDLE)
255
+ );
256
+ root.style.setProperty('--readout-meta-top-180', pct(READOUT_META_Y_180));
257
+ // When the frame lowered a crop for the label drop, the sector's static
258
+ // aspect-ratio no longer matches the viewBox — follow the frame so the
259
+ // dial keeps hugging the root box (contain behavior, issue #992).
260
+ if (frame.clipsAdjusted) {
261
+ root.style.setProperty(
262
+ '--gauge-radial-aspect',
263
+ `${frame.width} / ${frame.height}`
264
+ );
265
+ } else {
266
+ root.style.removeProperty('--gauge-radial-aspect');
267
+ }
268
+ };
269
+
215
270
  // Arrow form so `this` binds when passed as `.getAngle=${this.getAngle}`
216
271
  // to <obc-instrument-radial>. Do not convert to a method.
217
272
  getAngle = (v: number): number => {
@@ -304,6 +359,7 @@ export class ObcGaugeRadial extends SetpointMixin(LitElement) {
304
359
  <div
305
360
  class=${classMap({
306
361
  'gauge-radial-root': true,
362
+ 'face-pinned': this.faceDiameter !== undefined,
307
363
  'type-needle': this.type === ObcGaugeRadialType.needle,
308
364
  'sector-180': this.sector === GaugeRadialSector.deg180,
309
365
  'sector-90-left': this.sector === GaugeRadialSector.deg90Left,
@@ -341,6 +397,8 @@ export class ObcGaugeRadial extends SetpointMixin(LitElement) {
341
397
  .clipLeft=${clips.left}
342
398
  .clipRight=${clips.right}
343
399
  .endLabelsMaxMin=${this.sector === GaugeRadialSector.deg180}
400
+ .faceDiameter=${this.faceDiameter}
401
+ @frame-changed=${this._onFrameChanged}
344
402
  >
345
403
  </obc-instrument-radial>
346
404
  ${this.renderReadouts()}
@@ -5,7 +5,13 @@ import {
5
5
  ObcHeading,
6
6
  } from './heading.js';
7
7
  import './heading.js';
8
- import {widthDecorator} from '../../storybook-util.js';
8
+ import {html} from 'lit';
9
+ import {
10
+ playgroundColumn,
11
+ resizableStoryBox,
12
+ storyHint,
13
+ widthDecorator,
14
+ } from '../../storybook-util.js';
9
15
  import {AdviceType} from '../watch/advice.js';
10
16
  import {Priority} from '../types.js';
11
17
 
@@ -33,6 +39,11 @@ const meta: Meta<typeof ObcHeading> = {
33
39
  },
34
40
  argTypes: {
35
41
  width: {control: {type: 'range', min: 32, max: 1028, step: 1}},
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
  heading: {control: {type: 'range', min: 0, max: 360, step: 1}},
37
48
  courseOverGround: {control: {type: 'range', min: 0, max: 360, step: 1}},
38
49
  headingSetpoint: {control: {type: 'range', min: 0, max: 360, step: 1}},
@@ -67,3 +78,83 @@ export const Enhanced: Story = {
67
78
  headingSetpoint: 311,
68
79
  },
69
80
  };
81
+
82
+ /**
83
+ * Below the label-reserve cap (~200px for the NSWE + north-arrow decor) the
84
+ * frame reports `labelsHidden` and the instrument drops the labels and the
85
+ * north arrow instead of letting them clip — the same graceful degradation
86
+ * the tick-label instruments use (issue #1021).
87
+ */
88
+ export const SmallContainerDegraded: Story = {
89
+ name: 'Small Container (160px, Labels + Arrow Hidden Past Cap)',
90
+ args: {
91
+ width: 160,
92
+ },
93
+ };
94
+
95
+ type SizingPlaygroundArgs = Partial<ObcHeading> & {
96
+ lockFaceDiameter?: boolean;
97
+ };
98
+
99
+ /**
100
+ * Interactive sizing playground: drag the dashed box's bottom-right corner to
101
+ * resize it. The first instrument is pinned to a fixed intrinsic size by the
102
+ * `faceDiameter` control, while the second adapts to the remaining flex
103
+ * space, reserving room for the NSWE labels and north arrow adaptively
104
+ * (issue #1021). Enable `lockFaceDiameter` to pin both to the same
105
+ * circumference. Related: *Sizing Playground* stories under Building
106
+ * Blocks/Watch, Building Blocks/Instrument Radial and Instruments/Gauge
107
+ * Radial.
108
+ */
109
+ export const SizingPlayground: StoryObj<SizingPlaygroundArgs> = {
110
+ name: 'Sizing Playground — FaceDiameter + Resizable (Manual)',
111
+ tags: ['skip-test'],
112
+ parameters: {widthDecorator: false},
113
+ args: {
114
+ faceDiameter: 240,
115
+ lockFaceDiameter: false,
116
+ },
117
+ argTypes: {
118
+ lockFaceDiameter: {
119
+ control: 'boolean',
120
+ description:
121
+ 'Apply faceDiameter to every instance (equal circumference) instead of only the first.',
122
+ },
123
+ },
124
+ render: (args) => {
125
+ const instances = [
126
+ {label: 'heading A', heading: 311},
127
+ {label: 'heading B', heading: 45},
128
+ ];
129
+ const fd = (index: number) =>
130
+ index === 0 || args.lockFaceDiameter ? args.faceDiameter : undefined;
131
+ const caption = (index: number, label: string) =>
132
+ fd(index) !== undefined
133
+ ? `${label} — pinned ${fd(index)}px`
134
+ : `${label} — adaptive (flex)`;
135
+ return html`
136
+ ${storyHint(
137
+ 'Drag the bottom-right corner of the dashed box to resize it. The first instrument is pinned by the faceDiameter control; the second adapts to the remaining flex space. Enable lockFaceDiameter to pin both to the same circumference.'
138
+ )}
139
+ ${resizableStoryBox(
140
+ html`
141
+ ${instances.map((g, index) =>
142
+ playgroundColumn(
143
+ caption(index, g.label),
144
+ html`
145
+ <obc-heading
146
+ .heading=${g.heading}
147
+ .courseOverGround=${g.heading + 27}
148
+ .showLabels=${true}
149
+ .faceDiameter=${fd(index)}
150
+ ></obc-heading>
151
+ `,
152
+ {pinned: fd(index) !== undefined}
153
+ )
154
+ )}
155
+ `,
156
+ {width: 680, height: 400}
157
+ )}
158
+ `;
159
+ },
160
+ };
@@ -5,6 +5,14 @@ import {Tickmark, TickmarkType} from '../watch/tickmark.js';
5
5
  import {arrow, ArrowStyle} from './arrow.js';
6
6
  import {AdviceState, AngleAdvice, AngleAdviceRaw} from '../watch/advice.js';
7
7
  import {ResizeController} from '@lit-labs/observers/resize-controller.js';
8
+ import {
9
+ applyPinnedHostSize,
10
+ computeRadialFrame,
11
+ measureContainerPx,
12
+ NORTH_ARROW_WIDTH_PX,
13
+ NSWE_LABEL_WIDTH_PX,
14
+ type RadialFrame,
15
+ } from '../../svghelpers/radial-frame.js';
8
16
  import {WatchCircleType} from '../watch/watch.js';
9
17
  import {SetpointBundle} from '../../svghelpers/setpoint-bundle.js';
10
18
  import {Priority} from '../types.js';
@@ -58,6 +66,15 @@ export class ObcHeading extends LitElement {
58
66
  @property({type: Boolean}) showLabels: boolean = false;
59
67
  /** When true, labels and north arrow are placed inside the outer ring. */
60
68
  @property({type: Boolean}) tickmarksInside: boolean = false;
69
+ /**
70
+ * Outer-ring diameter in CSS pixels. When set, the instrument renders at a
71
+ * fixed intrinsic size derived from the ring, arc shape and label reserve —
72
+ * so instruments sharing the same value have identical ring circumference
73
+ * regardless of label width or arc extent (like obc-donut-chart's
74
+ * fixedHeight). When unset (default), the instrument fills its container.
75
+ */
76
+ @property({type: Number, attribute: 'face-diameter'})
77
+ faceDiameter: number | undefined;
61
78
 
62
79
  private _headingSp = new SetpointBundle({
63
80
  angularWraparound: true,
@@ -88,18 +105,30 @@ export class ObcHeading extends LitElement {
88
105
  // function is called on resize of the element
89
106
  private _resizeController = new ResizeController(this, {});
90
107
 
91
- private getPadding() {
92
- const size = Math.min(this.clientHeight, this.clientWidth);
93
- const deltaWidth = 512 - size;
94
- const steps = deltaWidth / 128;
95
- let deltaPadding;
96
- if (deltaWidth > 0) {
97
- deltaPadding = steps * 48;
98
- } else {
99
- deltaPadding = steps * 6;
108
+ /**
109
+ * Pixel cost of the outside decor: the always-rendered north-arrow glyph
110
+ * plus, only while shown, the NSWE labels (both keep a constant on-screen
111
+ * size via `1/scale` terms). Feeds the frame's width-aware reserve,
112
+ * replacing the former empirical `72 + delta(clientSize)` padding.
113
+ */
114
+ private getOutsideDecorPx(): number {
115
+ if (this.tickmarksInside) {
116
+ return 0;
100
117
  }
118
+ return NORTH_ARROW_WIDTH_PX + (this.showLabels ? NSWE_LABEL_WIDTH_PX : 0);
119
+ }
101
120
 
102
- return 72 + deltaPadding;
121
+ /** Whether the host size styles were set by applyPinnedHostSize. */
122
+ private _hostSizePinned = false;
123
+ private _frame: RadialFrame | undefined;
124
+
125
+ override updated(changed: PropertyValues): void {
126
+ super.updated(changed);
127
+ this._hostSizePinned = applyPinnedHostSize(
128
+ this,
129
+ this._frame,
130
+ this._hostSizePinned
131
+ );
103
132
  }
104
133
 
105
134
  private get angleAdviceRaw(): AngleAdviceRaw[] {
@@ -140,22 +169,27 @@ export class ObcHeading extends LitElement {
140
169
  {angle: 270, type: TickmarkType.main},
141
170
  ];
142
171
 
143
- const padding = this.getPadding();
144
- const width = (176 + padding) * 2;
145
- const viewBox = `-${width / 2} -${width / 2} ${width} ${width}`;
172
+ const frame = computeRadialFrame({
173
+ basePadding: 72,
174
+ labelWidthPx: this.getOutsideDecorPx(),
175
+ containerPx: measureContainerPx(this),
176
+ faceDiameter: this.faceDiameter,
177
+ });
178
+ this._frame = frame;
179
+ const viewBox = frame.viewBox;
146
180
 
147
181
  return html`
148
182
  <div class="container">
149
183
  <obc-watch
150
184
  .touching=${this.touching}
151
- .padding=${padding}
185
+ .arcFrame=${frame}
152
186
  .advices=${this.angleAdviceRaw}
153
187
  .tickmarks=${tickmarks}
154
188
  .watchCircleType=${WatchCircleType.single}
155
- .showLabels=${this.showLabels}
189
+ .showLabels=${this.showLabels && !frame.labelsHidden}
156
190
  .tickmarksInside=${this.tickmarksInside}
157
191
  .crosshairEnabled=${true}
158
- .northArrow=${true}
192
+ .northArrow=${!frame.labelsHidden}
159
193
  .angleSetpoint=${this.headingSetpoint ?? undefined}
160
194
  .newAngleSetpoint=${this.newHeadingSetpoint}
161
195
  .atAngleSetpoint=${this._headingSp.computeAtSetpoint(this.heading)}