@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
@@ -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 {
@@ -202,7 +208,21 @@ export class ObcWatch extends LitElement {
202
208
 
203
209
  /** Whether the setpoint CSS angle has been initialised (to skip transition on first render). */
204
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
+ */
205
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;
206
226
  @property({type: Array, attribute: false}) areas: WatchArea[] = [];
207
227
  @property({type: Array, attribute: false}) barAreas: WatchBarArea[] = [];
208
228
  @property({type: Array, attribute: false}) needles: WatchNeedle[] = [];
@@ -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
+ }
@@ -0,0 +1,307 @@
1
+ import {describe, expect, it} from 'vitest';
2
+ import {computeZoomToFitArcFrame} from './arc-frame.js';
3
+ import {
4
+ computeRadialFrame,
5
+ estimateLabelWidthPx,
6
+ LABEL_RESERVE_MAX_FRACTION,
7
+ } from './radial-frame.js';
8
+
9
+ const ZOOM_AREAS = [
10
+ {
11
+ startAngle: -135,
12
+ endAngle: 135,
13
+ roundInsideCut: true,
14
+ roundOutsideCut: true,
15
+ },
16
+ ];
17
+
18
+ describe('computeRadialFrame — back-compat identity', () => {
19
+ it('no labels reproduces the legacy (176+padding)*2 box', () => {
20
+ const f = computeRadialFrame({basePadding: 48});
21
+ expect(f.viewBox).toBe('-224 -224 448 448');
22
+ expect(f.width).toBe(448);
23
+ expect(f.height).toBe(448);
24
+ expect(f.x).toBe(-224);
25
+ expect(f.y).toBe(-224);
26
+ expect(f.radiusOffset).toBe(0);
27
+ expect(f.labelReserve).toBe(0);
28
+ expect(f.labelsHidden).toBe(false);
29
+ expect(f.hostWidthPx).toBeUndefined();
30
+ });
31
+
32
+ it('no labels + clips reproduces the legacy clipped box (rudder: top 40%)', () => {
33
+ const f = computeRadialFrame({
34
+ basePadding: 48,
35
+ clips: {top: 40, bottom: 0, left: 0, right: 0},
36
+ });
37
+ expect(f.x).toBe(-224);
38
+ expect(f.y).toBeCloseTo(-44.8);
39
+ expect(f.width).toBe(448);
40
+ expect(f.height).toBeCloseTo(268.8);
41
+ });
42
+
43
+ it('labels with a generous container never shrink below the base box', () => {
44
+ const f = computeRadialFrame({
45
+ basePadding: 48,
46
+ labelWidthPx: 14,
47
+ containerPx: {width: 2000, height: 2000},
48
+ });
49
+ expect(f.width).toBe(448);
50
+ expect(f.labelReserve).toBe(0);
51
+ expect(f.labelsHidden).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('computeRadialFrame — mode (a) label reserve', () => {
56
+ it('grows the box for 4-digit labels in a shrunk container', () => {
57
+ const f = computeRadialFrame({
58
+ basePadding: 48,
59
+ labelWidthPx: 28,
60
+ containerPx: {width: 200, height: 200},
61
+ });
62
+ // P = 9 + 28 + 4 = 41; side = 374 / (1 - 82/200)
63
+ expect(f.width).toBeCloseTo(374 / (1 - 82 / 200), 6);
64
+ expect(f.width).toBeGreaterThan(448);
65
+ expect(f.labelsHidden).toBe(false);
66
+ expect(f.labelReserve).toBeCloseTo((f.width - 448) / 2, 6);
67
+ // The label's far edge must fit inside the box at the implied scale.
68
+ const scale = 200 / f.width;
69
+ expect(f.scale).toBeCloseTo(scale, 6);
70
+ expect(187 + 41 / scale).toBeLessThanOrEqual(f.width / 2 + 1e-6);
71
+ });
72
+
73
+ it('hides labels past the reserve cap instead of growing unboundedly', () => {
74
+ const f = computeRadialFrame({
75
+ basePadding: 48,
76
+ labelWidthPx: 28,
77
+ containerPx: {width: 120, height: 120},
78
+ });
79
+ expect((2 * 41) / 120).toBeGreaterThan(LABEL_RESERVE_MAX_FRACTION);
80
+ expect(f.labelsHidden).toBe(true);
81
+ expect(f.labelReserve).toBe(0);
82
+ expect(f.width).toBe(448);
83
+ });
84
+
85
+ it('uses the binding axis with clips (180° sector in a short wide cell)', () => {
86
+ const f = computeRadialFrame({
87
+ basePadding: 48,
88
+ labelWidthPx: 21,
89
+ clips: {top: 0, bottom: 44, left: 0, right: 0},
90
+ containerPx: {width: 600, height: 120},
91
+ });
92
+ // fy = 0.56 → Ceff = min(600/1, 120/0.56); P = 34
93
+ const ceff = 120 / 0.56;
94
+ const side = 374 / (1 - 68 / ceff);
95
+ expect(f.width).toBeCloseTo(side, 6);
96
+ expect(f.height).toBeCloseTo(side * 0.56, 6);
97
+ });
98
+
99
+ it('falls back to scale 1 for a zero-size container (first paint)', () => {
100
+ const f = computeRadialFrame({
101
+ basePadding: 48,
102
+ labelWidthPx: 28,
103
+ containerPx: {width: 0, height: 0},
104
+ });
105
+ expect(Number.isFinite(f.width)).toBe(true);
106
+ expect(f.labelsHidden).toBe(false);
107
+ expect(f.width).toBeCloseTo(2 * (187 + 41), 6);
108
+ });
109
+ });
110
+
111
+ describe('computeRadialFrame — labelDropPx clip adjustment', () => {
112
+ const SECTOR_180 = {top: 0, bottom: 44, left: 0, right: 0};
113
+
114
+ it('leaves the requested clips untouched when the drop already fits', () => {
115
+ const plain = computeRadialFrame({
116
+ basePadding: 48,
117
+ labelWidthPx: 21,
118
+ clips: SECTOR_180,
119
+ containerPx: {width: 400, height: 224},
120
+ });
121
+ const withDrop = computeRadialFrame({
122
+ basePadding: 48,
123
+ labelWidthPx: 21,
124
+ clips: SECTOR_180,
125
+ labelDropPx: 21,
126
+ containerPx: {width: 400, height: 224},
127
+ });
128
+ expect(withDrop.viewBox).toBe(plain.viewBox);
129
+ expect(withDrop.clipsAdjusted).toBe(false);
130
+ expect(plain.clipsAdjusted).toBe(false);
131
+ });
132
+
133
+ it('lowers the bottom clip when a pinned face would cut the end labels', () => {
134
+ const plain = computeRadialFrame({
135
+ basePadding: 48,
136
+ labelWidthPx: 21,
137
+ clips: SECTOR_180,
138
+ faceDiameter: 180,
139
+ });
140
+ const f = computeRadialFrame({
141
+ basePadding: 48,
142
+ labelWidthPx: 21,
143
+ clips: SECTOR_180,
144
+ labelDropPx: 21,
145
+ faceDiameter: 180,
146
+ });
147
+ expect(f.clipsAdjusted).toBe(true);
148
+ expect(f.height).toBeGreaterThan(plain.height);
149
+ // The box bottom must sit at least labelDropPx/scale below the center.
150
+ const bottomExtentUnits = f.y + f.height;
151
+ expect(bottomExtentUnits).toBeGreaterThanOrEqual(21 / f.scale - 1e-6);
152
+ // The width (and thus the ring scale) is not affected.
153
+ expect(f.width).toBeCloseTo(plain.width, 6);
154
+ expect(f.scale).toBeCloseTo(plain.scale, 6);
155
+ });
156
+
157
+ it('adjusts symmetrically for a top clip (rudder-style windows)', () => {
158
+ const f = computeRadialFrame({
159
+ basePadding: 48,
160
+ labelWidthPx: 21,
161
+ clips: {top: 47, bottom: 0, left: 0, right: 0},
162
+ labelDropPx: 21,
163
+ faceDiameter: 180,
164
+ });
165
+ expect(f.clipsAdjusted).toBe(true);
166
+ // The box top must sit at least labelDropPx/scale above the center.
167
+ expect(-f.y).toBeGreaterThanOrEqual(21 / f.scale - 1e-6);
168
+ });
169
+
170
+ it('does nothing without clips or when labels are hidden', () => {
171
+ const noClips = computeRadialFrame({
172
+ basePadding: 48,
173
+ labelWidthPx: 21,
174
+ labelDropPx: 21,
175
+ containerPx: {width: 400, height: 400},
176
+ });
177
+ expect(noClips.clipsAdjusted).toBe(false);
178
+
179
+ const hidden = computeRadialFrame({
180
+ basePadding: 48,
181
+ labelWidthPx: 28,
182
+ clips: SECTOR_180,
183
+ labelDropPx: 21,
184
+ containerPx: {width: 100, height: 56},
185
+ });
186
+ expect(hidden.labelsHidden).toBe(true);
187
+ expect(hidden.clipsAdjusted).toBe(false);
188
+ expect(hidden.height).toBeCloseTo(hidden.width * 0.56, 6);
189
+ });
190
+ });
191
+
192
+ describe('computeRadialFrame — mode (b) faceDiameter', () => {
193
+ it('pins the scale and emits fixed host px', () => {
194
+ const f = computeRadialFrame({basePadding: 48, faceDiameter: 368});
195
+ expect(f.scale).toBe(1);
196
+ expect(f.hostWidthPx).toBeCloseTo(448);
197
+ expect(f.hostHeightPx).toBeCloseTo(448);
198
+ });
199
+
200
+ it('same faceDiameter, wider labels → same ring scale, wider box', () => {
201
+ const a = computeRadialFrame({
202
+ basePadding: 48,
203
+ faceDiameter: 200,
204
+ labelWidthPx: 14,
205
+ });
206
+ const b = computeRadialFrame({
207
+ basePadding: 48,
208
+ faceDiameter: 200,
209
+ labelWidthPx: 28,
210
+ });
211
+ expect(a.scale).toBeCloseTo(b.scale, 6);
212
+ expect(b.hostWidthPx!).toBeGreaterThan(a.hostWidthPx!);
213
+ });
214
+
215
+ it('treats zero/negative/non-finite faceDiameter as unset (no host pin)', () => {
216
+ for (const bad of [0, -100, NaN, Infinity]) {
217
+ const f = computeRadialFrame({
218
+ basePadding: 48,
219
+ faceDiameter: bad,
220
+ containerPx: {width: 300, height: 300},
221
+ });
222
+ expect(f.hostWidthPx).toBeUndefined();
223
+ expect(f.hostHeightPx).toBeUndefined();
224
+ expect(f.viewBox).toBe('-224 -224 448 448');
225
+ }
226
+ });
227
+
228
+ it('treats an unusable faceDiameter as unset on the zoom path too', () => {
229
+ const f = computeRadialFrame({
230
+ basePadding: 48,
231
+ faceDiameter: NaN,
232
+ zoomToFitArc: true,
233
+ areas: ZOOM_AREAS,
234
+ innerRadius: 112,
235
+ containerPx: {width: 300, height: 300},
236
+ });
237
+ expect(f.hostWidthPx).toBeUndefined();
238
+ expect(f.hostHeightPx).toBeUndefined();
239
+ });
240
+
241
+ it('faceDiameter + 180° clips yields a shorter host box at the same scale', () => {
242
+ const full = computeRadialFrame({basePadding: 48, faceDiameter: 200});
243
+ const half = computeRadialFrame({
244
+ basePadding: 48,
245
+ faceDiameter: 200,
246
+ clips: {top: 0, bottom: 44, left: 0, right: 0},
247
+ });
248
+ expect(half.scale).toBeCloseTo(full.scale, 6);
249
+ expect(half.hostWidthPx!).toBeCloseTo(full.hostWidthPx!, 6);
250
+ expect(half.hostHeightPx!).toBeCloseTo(full.hostHeightPx! * 0.56, 6);
251
+ });
252
+ });
253
+
254
+ describe('computeRadialFrame — zoom path', () => {
255
+ it('matches computeZoomToFitArcFrame exactly when no labels', () => {
256
+ const f = computeRadialFrame({
257
+ basePadding: 48,
258
+ zoomToFitArc: true,
259
+ areas: ZOOM_AREAS,
260
+ innerRadius: 112,
261
+ });
262
+ const direct = computeZoomToFitArcFrame({
263
+ areas: ZOOM_AREAS,
264
+ outerRadius: 184,
265
+ innerRadius: 112,
266
+ extension: 48,
267
+ targetSize: 448,
268
+ });
269
+ expect(f.viewBox).toBe(direct.viewBox);
270
+ expect(f.width).toBe(direct.width);
271
+ expect(f.height).toBe(direct.height);
272
+ expect(f.radiusOffset).toBe(direct.radiusOffset);
273
+ expect(f.labelReserve).toBe(0);
274
+ });
275
+
276
+ it('adds a label reserve via the extension in a small container', () => {
277
+ const plain = computeRadialFrame({
278
+ basePadding: 48,
279
+ zoomToFitArc: true,
280
+ areas: ZOOM_AREAS,
281
+ innerRadius: 112,
282
+ });
283
+ const labeled = computeRadialFrame({
284
+ basePadding: 48,
285
+ zoomToFitArc: true,
286
+ areas: ZOOM_AREAS,
287
+ innerRadius: 112,
288
+ labelWidthPx: 28,
289
+ containerPx: {width: 300, height: 300},
290
+ });
291
+ expect(labeled.labelsHidden).toBe(false);
292
+ expect(labeled.labelReserve).toBeGreaterThan(0);
293
+ expect(labeled.width).toBeGreaterThan(plain.width);
294
+ });
295
+ });
296
+
297
+ describe('estimateLabelWidthPx', () => {
298
+ it('is maxChars * 7', () => {
299
+ expect(estimateLabelWidthPx(['0', '3600', undefined])).toBe(28);
300
+ expect(estimateLabelWidthPx(['5'])).toBe(7);
301
+ });
302
+
303
+ it('is 0 for no texts', () => {
304
+ expect(estimateLabelWidthPx([undefined])).toBe(0);
305
+ expect(estimateLabelWidthPx([])).toBe(0);
306
+ });
307
+ });