@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
@@ -0,0 +1,508 @@
1
+ import type {WatchArea} from '../navigation-instruments/watch/watch.js';
2
+ import {computeZoomToFitArcFrame, type ZoomToFitArcFrame} from './arc-frame.js';
3
+
4
+ /**
5
+ * Shared frame computation for circular/radial watch-based instruments.
6
+ *
7
+ * Every radial instrument (watch, instrument-radial/gauge-radial, speed-gauge,
8
+ * azimuth-thruster, compass, heading, rudder, pitch/roll, …) needs the same
9
+ * three outputs to render: an SVG viewBox for `<obc-watch>` and its overlay,
10
+ * a zoom radius offset, and — since issue #1021 — enough reserved room around
11
+ * the outer ring that tick labels are not clipped when the instrument shrinks
12
+ * inside a flex container.
13
+ *
14
+ * `computeRadialFrame()` is the single source of truth for that geometry.
15
+ * Consumers call it once per render and pass the resulting frame to
16
+ * `<obc-watch .arcFrame=...>` (which uses it verbatim) and to their own
17
+ * overlay `<svg viewBox=...>`, guaranteeing the two layers stay aligned.
18
+ *
19
+ * ## Label reserve (issue #1021)
20
+ *
21
+ * Tick labels render at `font-size: calc(12px / scale)` so they keep a
22
+ * constant on-screen size while the ring scales. Their footprint in SVG units
23
+ * is therefore `px / scale`, where `scale = containerPx / viewBoxSide` — the
24
+ * smaller the instrument, the more SVG units a label needs. The frame solves
25
+ * the resulting equation in closed form:
26
+ *
27
+ * ```
28
+ * side/2 >= (OUTER_RING_RADIUS + LABEL_RADIAL_PAD_UNITS) + P/scale
29
+ * scale = containerPx / side
30
+ * => side = 2 * (OUTER_RING_RADIUS + LABEL_RADIAL_PAD_UNITS) / (1 - 2P/containerPx)
31
+ * ```
32
+ *
33
+ * where `P` is the label's pixel cost beyond the ring (anchor offsets + text
34
+ * width + edge padding). The viewBox never shrinks below the legacy
35
+ * `(176 + basePadding) * 2` box, so instruments without labels — or with
36
+ * labels that already fit — render byte-identical to the pre-#1021 output.
37
+ *
38
+ * When the reserve would exceed `LABEL_RESERVE_MAX_FRACTION` of the box, the
39
+ * frame reports `labelsHidden: true` and falls back to the base box; callers
40
+ * must then strip tick label texts (graceful degradation instead of clipping,
41
+ * mirroring the canvas charts).
42
+ *
43
+ * ## Equal circumference (`faceDiameter`)
44
+ *
45
+ * When `faceDiameter` (px) is given, the outer-ring diameter is pinned:
46
+ * `scale = faceDiameter / 368`. The frame then reports `hostWidthPx` /
47
+ * `hostHeightPx` so the host element can adopt a fixed intrinsic size, and
48
+ * instruments sharing a `faceDiameter` value have identical ring
49
+ * circumference regardless of label width or arc extent — the SVG counterpart
50
+ * of `obc-donut-chart`'s fixed-height ⇒ fixed-circumference contract.
51
+ */
52
+
53
+ // Keep in sync with watch.ts OUTER_RING_RADIUS (importing it would be circular).
54
+ const OUTER_RING_RADIUS = 368 / 2;
55
+ /** Legacy viewBox base: `side = (RADIAL_VIEWBOX_BASE + padding) * 2`. */
56
+ const RADIAL_VIEWBOX_BASE = 176;
57
+ /** Non-scaled part of the tickmark text radius pad (`3/scale + 3` in tickmark.ts). */
58
+ const LABEL_RADIAL_PAD_UNITS = 3;
59
+ /** Scaled label anchor offsets in px: `3/scale` textRadius pad + `6/scale` xOffset. */
60
+ const LABEL_ANCHOR_OFFSET_PX = 9;
61
+ /** Breathing room between the label's far edge and the viewBox edge, in px. */
62
+ const LABEL_EDGE_PAD_PX = 4;
63
+ /** Approximate digit advance at 12px font-instrument-label. */
64
+ export const DIGIT_WIDTH_PX = 7;
65
+ /** Compass NSWE label width assumed by label.ts (`labelWidth = 16`). */
66
+ export const NSWE_LABEL_WIDTH_PX = 16;
67
+ /**
68
+ * North-arrow glyph allowance for outside-decor reserves (px). The arrow
69
+ * renders regardless of `showLabels`, so consumers charge this portion
70
+ * whenever the arrow sits outside the ring.
71
+ */
72
+ export const NORTH_ARROW_WIDTH_PX = 16;
73
+ /**
74
+ * Labels are dropped (not clipped) when their reserve would exceed this share
75
+ * of the box. 0.45 makes a 4-digit gauge hide labels below a ~182px container
76
+ * — near the canvas charts' fixed MIN_HEIGHT_WITH_LABELS = 192px threshold —
77
+ * while 2-digit labels survive down to ~120px. Designers may later prefer a
78
+ * fixed pixel threshold instead (see charthelpers/constants.ts).
79
+ */
80
+ export const LABEL_RESERVE_MAX_FRACTION = 0.45;
81
+ /**
82
+ * Pixel drop of "Max-min" end labels (`endLabelsMaxMin`) below the ±90° line:
83
+ * anchored at `12px/scale` with `dominant-baseline: central` (tickmark.ts),
84
+ * so the glyph bottom reaches ~12 + 6 + descender ≈ 20px — plus 1px pad.
85
+ */
86
+ export const END_MAXMIN_LABEL_DROP_PX = 21;
87
+ /**
88
+ * Pixel drop of regular side labels at the ±90° ends: glyph centered on the
89
+ * line (half-height ~6px + descender), plus pad.
90
+ */
91
+ export const SIDE_LABEL_DROP_PX = 13;
92
+
93
+ /** Per-edge viewBox crops in percent (0–100), matching obc-watch `clip*`. */
94
+ export interface RadialClips {
95
+ top: number;
96
+ bottom: number;
97
+ left: number;
98
+ right: number;
99
+ }
100
+
101
+ export interface RadialFrameOptions {
102
+ /** Legacy padding: the frame never shrinks below `(176 + basePadding) * 2`. */
103
+ basePadding: number;
104
+ /** Max label pixel width at 12px font; 0/undefined = no outside labels. */
105
+ labelWidthPx?: number;
106
+ /** Un-zoomed viewBox crops; ignored when `zoomToFitArc` is set. */
107
+ clips?: RadialClips;
108
+ /**
109
+ * Pixel extent the outside labels need past the ±90° horizontal line
110
+ * toward a top/bottom crop edge (see END_MAXMIN_LABEL_DROP_PX /
111
+ * SIDE_LABEL_DROP_PX). When a `clips.top`/`clips.bottom` crop would cut
112
+ * into that drop, the clip is lowered so the labels stay inside the box.
113
+ * Absent/0 keeps the requested clips untouched (legacy geometry).
114
+ */
115
+ labelDropPx?: number;
116
+ /** Host client box in px; absent or zero ⇒ scale-1 fallback (first paint). */
117
+ containerPx?: {width: number; height: number};
118
+ /** Outer-ring diameter in px; pins scale for equal circumference (mode b). */
119
+ faceDiameter?: number;
120
+ zoomToFitArc?: boolean;
121
+ /** Visible arc segments; required for the zoom path. */
122
+ areas?: WatchArea[];
123
+ /** Innermost visible radius (depends on WatchCircleType); zoom path only. */
124
+ innerRadius?: number;
125
+ }
126
+
127
+ export interface RadialFrame extends ZoomToFitArcFrame {
128
+ /** Px per SVG unit implied by container or faceDiameter (1 when unknown). */
129
+ scale: number;
130
+ /** SVG units added beyond the base geometry for labels (0 when none/hidden). */
131
+ labelReserve: number;
132
+ /** True when the label reserve exceeded the cap; callers strip tick texts. */
133
+ labelsHidden: boolean;
134
+ /**
135
+ * True when `labelDropPx` lowered a top/bottom clip: the box's aspect no
136
+ * longer matches the requested clip fractions, so hosts with an
137
+ * aspect-ratio derived from those fractions must follow the frame instead.
138
+ */
139
+ clipsAdjusted: boolean;
140
+ /** Fixed host size in px; defined only when `faceDiameter` is set. */
141
+ hostWidthPx?: number;
142
+ /** Fixed host size in px; defined only when `faceDiameter` is set. */
143
+ hostHeightPx?: number;
144
+ }
145
+
146
+ /**
147
+ * Measure the host's available box in px. Radial instrument hosts render
148
+ * inline by default, so clientWidth/Height are often 0 — fall back to the
149
+ * parent box, like obc-watch's getScale() has always done (issue #1032).
150
+ */
151
+ export function measureContainerPx(host: HTMLElement): {
152
+ width: number;
153
+ height: number;
154
+ } {
155
+ let width = host.clientWidth;
156
+ let height = host.clientHeight;
157
+ if (width === 0 || height === 0) {
158
+ const box = host.parentElement?.getBoundingClientRect();
159
+ if (box) {
160
+ width = box.width;
161
+ height = box.height;
162
+ }
163
+ }
164
+ return {width, height};
165
+ }
166
+
167
+ /**
168
+ * Apply/remove the fixed intrinsic host size a `faceDiameter` frame pins.
169
+ * The hosts render inline by default, so a pinned size also needs
170
+ * `display: block` for width/height to apply. Returns whether the size is
171
+ * currently pinned; pass that back as `wasPinned` on the next call so an
172
+ * unpinned host's own inline styles are never clobbered.
173
+ */
174
+ export function applyPinnedHostSize(
175
+ host: HTMLElement,
176
+ frame: RadialFrame | undefined,
177
+ wasPinned: boolean
178
+ ): boolean {
179
+ if (frame?.hostWidthPx !== undefined && frame.hostHeightPx !== undefined) {
180
+ host.style.width = `${frame.hostWidthPx}px`;
181
+ host.style.height = `${frame.hostHeightPx}px`;
182
+ host.style.display = 'block';
183
+ return true;
184
+ }
185
+ if (wasPinned) {
186
+ host.style.removeProperty('width');
187
+ host.style.removeProperty('height');
188
+ host.style.removeProperty('display');
189
+ }
190
+ return false;
191
+ }
192
+
193
+ /**
194
+ * Additionally observe an internal element that always generates a box (the
195
+ * shadow `<svg>` or `.container`). Several radial hosts render inline (no
196
+ * `:host {display}` rule), and ResizeObserver never fires for inline boxes —
197
+ * the host reports a permanent 0×0 size, so a controller observing only the
198
+ * host misses every resize. That freezes the container-dependent frame and
199
+ * the `--scale` font counter-scaling at their first-paint values.
200
+ */
201
+ export function observeInnerBox(
202
+ controller: {observe(target: Element): void},
203
+ renderRoot: ParentNode
204
+ ): void {
205
+ const box = renderRoot.querySelector('svg, .container');
206
+ if (box) {
207
+ controller.observe(box);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Estimate the widest label's pixel width at the 12px instrument label font.
213
+ */
214
+ export function estimateLabelWidthPx(
215
+ texts: readonly (string | undefined)[]
216
+ ): number {
217
+ let maxChars = 0;
218
+ for (const t of texts) {
219
+ if (t !== undefined && t.length > maxChars) {
220
+ maxChars = t.length;
221
+ }
222
+ }
223
+ return maxChars * DIGIT_WIDTH_PX;
224
+ }
225
+
226
+ function clipFractions(clips?: RadialClips): {fx: number; fy: number} {
227
+ if (!clips) {
228
+ return {fx: 1, fy: 1};
229
+ }
230
+ const fx = 1 - (clips.left + clips.right) / 100;
231
+ const fy = 1 - (clips.top + clips.bottom) / 100;
232
+ return {fx: fx > 0 ? fx : 1, fy: fy > 0 ? fy : 1};
233
+ }
234
+
235
+ function buildViewBox(side: number, clips?: RadialClips): ZoomToFitArcFrame {
236
+ const {fx, fy} = clipFractions(clips);
237
+ const width = side * fx;
238
+ const height = side * fy;
239
+ const x = -side / 2 + (side * (clips?.left ?? 0)) / 100;
240
+ const y = -side / 2 + (side * (clips?.top ?? 0)) / 100;
241
+ return {
242
+ x,
243
+ y,
244
+ width,
245
+ height,
246
+ viewBox: `${x} ${y} ${width} ${height}`,
247
+ radiusOffset: 0,
248
+ };
249
+ }
250
+
251
+ function pinnedScale(opts: RadialFrameOptions): number | undefined {
252
+ if (opts.faceDiameter === undefined) {
253
+ return undefined;
254
+ }
255
+ const scale = opts.faceDiameter / (2 * OUTER_RING_RADIUS);
256
+ return Number.isFinite(scale) && scale > 0 ? scale : undefined;
257
+ }
258
+
259
+ /** Container px available for the full (un-clipped) square, or undefined. */
260
+ function effectiveContainer(
261
+ opts: RadialFrameOptions,
262
+ fx: number,
263
+ fy: number
264
+ ): number | undefined {
265
+ const w = opts.containerPx?.width ?? 0;
266
+ const h = opts.containerPx?.height ?? 0;
267
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
268
+ return undefined;
269
+ }
270
+ return Math.min(w / fx, h / fy);
271
+ }
272
+
273
+ function containerScale(
274
+ opts: RadialFrameOptions,
275
+ frame: {width: number; height: number}
276
+ ): number | undefined {
277
+ const w = opts.containerPx?.width ?? 0;
278
+ const h = opts.containerPx?.height ?? 0;
279
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
280
+ return undefined;
281
+ }
282
+ const scale = Math.min(w / frame.width, h / frame.height);
283
+ return Number.isFinite(scale) && scale > 0 ? scale : undefined;
284
+ }
285
+
286
+ function withHostPx(
287
+ frame: ZoomToFitArcFrame,
288
+ pinned: boolean,
289
+ scale: number,
290
+ labelReserve: number,
291
+ labelsHidden: boolean,
292
+ clipsAdjusted = false
293
+ ): RadialFrame {
294
+ const result: RadialFrame = {
295
+ ...frame,
296
+ scale,
297
+ labelReserve,
298
+ labelsHidden,
299
+ clipsAdjusted,
300
+ };
301
+ if (pinned) {
302
+ result.hostWidthPx = frame.width * scale;
303
+ result.hostHeightPx = frame.height * scale;
304
+ }
305
+ return result;
306
+ }
307
+
308
+ /**
309
+ * Solve the un-zoomed box side and label visibility for a given clip window.
310
+ */
311
+ function solveSide(
312
+ opts: RadialFrameOptions,
313
+ labelCostPx: number,
314
+ clips: RadialClips | undefined
315
+ ): {side: number; labelsHidden: boolean} {
316
+ const baseSide = (RADIAL_VIEWBOX_BASE + opts.basePadding) * 2;
317
+ const {fx, fy} = clipFractions(clips);
318
+ const labelSideAt = (scale: number): number =>
319
+ 2 * (OUTER_RING_RADIUS + LABEL_RADIAL_PAD_UNITS + labelCostPx / scale);
320
+
321
+ let side = baseSide;
322
+ let labelsHidden = false;
323
+
324
+ if (labelCostPx > 0) {
325
+ const pinned = pinnedScale(opts);
326
+ if (pinned !== undefined) {
327
+ const grown = Math.max(baseSide, labelSideAt(pinned));
328
+ labelsHidden =
329
+ (2 * (labelCostPx / pinned)) / grown > LABEL_RESERVE_MAX_FRACTION;
330
+ side = labelsHidden ? baseSide : grown;
331
+ } else {
332
+ const container = effectiveContainer(opts, fx, fy);
333
+ if (container === undefined) {
334
+ side = Math.max(baseSide, labelSideAt(1));
335
+ } else if ((2 * labelCostPx) / container > LABEL_RESERVE_MAX_FRACTION) {
336
+ labelsHidden = true;
337
+ } else {
338
+ side = Math.max(
339
+ baseSide,
340
+ (2 * (OUTER_RING_RADIUS + LABEL_RADIAL_PAD_UNITS)) /
341
+ (1 - (2 * labelCostPx) / container)
342
+ );
343
+ }
344
+ }
345
+ }
346
+
347
+ return {side, labelsHidden};
348
+ }
349
+
350
+ /**
351
+ * Lower a top/bottom clip so the crop edge keeps `labelDropPx / scale` SVG
352
+ * units past the ±90° horizontal line (where the arc end labels hang).
353
+ * Returns the input object unchanged when the requested clips already fit.
354
+ */
355
+ function adjustClipsForLabelDrop(
356
+ clips: RadialClips,
357
+ side: number,
358
+ scale: number,
359
+ labelDropPx: number
360
+ ): RadialClips {
361
+ const requiredUnits = labelDropPx / scale;
362
+ const requiredClipPct = Math.max(0, (0.5 - requiredUnits / side) * 100);
363
+ const bottom =
364
+ clips.bottom > requiredClipPct ? requiredClipPct : clips.bottom;
365
+ const top = clips.top > requiredClipPct ? requiredClipPct : clips.top;
366
+ if (bottom === clips.bottom && top === clips.top) {
367
+ return clips;
368
+ }
369
+ return {...clips, top, bottom};
370
+ }
371
+
372
+ /**
373
+ * Compute the viewBox frame for a radial instrument, reserving width-aware
374
+ * room for outside tick labels. See the module documentation for the model.
375
+ */
376
+ export function computeRadialFrame(opts: RadialFrameOptions): RadialFrame {
377
+ const labelWidthPx = opts.labelWidthPx ?? 0;
378
+ const labelCostPx =
379
+ labelWidthPx > 0
380
+ ? LABEL_ANCHOR_OFFSET_PX + labelWidthPx + LABEL_EDGE_PAD_PX
381
+ : 0;
382
+
383
+ if (opts.zoomToFitArc && opts.areas && opts.areas.length > 0) {
384
+ return computeZoomedFrame(opts, labelCostPx);
385
+ }
386
+
387
+ const baseSide = (RADIAL_VIEWBOX_BASE + opts.basePadding) * 2;
388
+ let clips = opts.clips;
389
+ let {side, labelsHidden} = solveSide(opts, labelCostPx, clips);
390
+
391
+ // Label drop past the ±90° line: lower a top/bottom crop that would cut
392
+ // into it. The clip change alters the container fit (fy grows), so run a
393
+ // second pass; visually exact after two.
394
+ const labelDropPx = opts.labelDropPx ?? 0;
395
+ if (labelDropPx > 0 && labelCostPx > 0 && !labelsHidden && clips) {
396
+ for (let i = 0; i < 2; i++) {
397
+ const scale =
398
+ pinnedScale(opts) ??
399
+ containerScale(opts, buildViewBox(side, clips)) ??
400
+ 1;
401
+ const adjusted = adjustClipsForLabelDrop(
402
+ clips!,
403
+ side,
404
+ scale,
405
+ labelDropPx
406
+ );
407
+ if (adjusted === clips) {
408
+ break;
409
+ }
410
+ clips = adjusted;
411
+ const solved = solveSide(opts, labelCostPx, clips);
412
+ side = solved.side;
413
+ labelsHidden = solved.labelsHidden;
414
+ }
415
+ if (labelsHidden) {
416
+ // The taller window pushed the reserve past the cap: labels are
417
+ // stripped, so the drop is moot — restore the requested clips.
418
+ clips = opts.clips;
419
+ const solved = solveSide(opts, labelCostPx, clips);
420
+ side = solved.side;
421
+ labelsHidden = solved.labelsHidden;
422
+ }
423
+ }
424
+
425
+ const frame = buildViewBox(side, clips);
426
+ const pinned = pinnedScale(opts);
427
+ const scale = pinned ?? containerScale(opts, frame) ?? 1;
428
+ return withHostPx(
429
+ frame,
430
+ pinned !== undefined,
431
+ scale,
432
+ (side - baseSide) / 2,
433
+ labelsHidden,
434
+ clips !== opts.clips
435
+ );
436
+ }
437
+
438
+ function computeZoomedFrame(
439
+ opts: RadialFrameOptions,
440
+ labelCostPx: number
441
+ ): RadialFrame {
442
+ let reserve = 0;
443
+ let labelsHidden = false;
444
+ let frame: ZoomToFitArcFrame = zoomFrameAt(opts, opts.basePadding);
445
+
446
+ if (labelCostPx > 0) {
447
+ // Fixed-point on the extension: the reserve depends on the scale, and the
448
+ // scale on the frame size. Converges monotonically; two passes suffice.
449
+ for (let i = 0; i < 2; i++) {
450
+ const passScale = zoomedScale(opts, frame);
451
+ const reserveUnits = labelCostPx / passScale;
452
+ if (
453
+ (2 * reserveUnits) / Math.max(frame.width, frame.height) >
454
+ LABEL_RESERVE_MAX_FRACTION
455
+ ) {
456
+ labelsHidden = true;
457
+ reserve = 0;
458
+ frame = zoomFrameAt(opts, opts.basePadding);
459
+ break;
460
+ }
461
+ reserve = reserveUnits;
462
+ frame = zoomFrameAt(opts, opts.basePadding + reserve);
463
+ }
464
+ }
465
+
466
+ const pinned = pinnedZoomedScale(opts, frame);
467
+ const scale = pinned ?? containerScale(opts, frame) ?? 1;
468
+ return withHostPx(
469
+ frame,
470
+ pinned !== undefined,
471
+ scale,
472
+ labelsHidden ? 0 : reserve,
473
+ labelsHidden
474
+ );
475
+ }
476
+
477
+ function zoomFrameAt(
478
+ opts: RadialFrameOptions,
479
+ extension: number
480
+ ): ZoomToFitArcFrame {
481
+ return computeZoomToFitArcFrame({
482
+ areas: opts.areas!,
483
+ outerRadius: OUTER_RING_RADIUS,
484
+ innerRadius: opts.innerRadius ?? OUTER_RING_RADIUS,
485
+ extension,
486
+ targetSize: (RADIAL_VIEWBOX_BASE + extension) * 2,
487
+ });
488
+ }
489
+
490
+ /** Pin scale for a zoomed frame, or undefined when `faceDiameter` is unusable. */
491
+ function pinnedZoomedScale(
492
+ opts: RadialFrameOptions,
493
+ frame: ZoomToFitArcFrame
494
+ ): number | undefined {
495
+ if (opts.faceDiameter === undefined) {
496
+ return undefined;
497
+ }
498
+ const scale =
499
+ opts.faceDiameter / (2 * (OUTER_RING_RADIUS + frame.radiusOffset));
500
+ return Number.isFinite(scale) && scale > 0 ? scale : undefined;
501
+ }
502
+
503
+ function zoomedScale(
504
+ opts: RadialFrameOptions,
505
+ frame: ZoomToFitArcFrame
506
+ ): number {
507
+ return pinnedZoomedScale(opts, frame) ?? containerScale(opts, frame) ?? 1;
508
+ }