@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.
- package/bundle/openbridge-webcomponents.bundle.js +579 -143
- package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
- package/custom-elements.json +338 -8
- package/dist/building-blocks/instrument-radial/instrument-radial.d.ts +22 -2
- package/dist/building-blocks/instrument-radial/instrument-radial.d.ts.map +1 -1
- package/dist/building-blocks/instrument-radial/instrument-radial.js +61 -37
- package/dist/building-blocks/instrument-radial/instrument-radial.js.map +1 -1
- package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.d.ts +14 -0
- package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.d.ts.map +1 -1
- package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.js +30 -11
- package/dist/navigation-instruments/azimuth-thruster/azimuth-thruster.js.map +1 -1
- package/dist/navigation-instruments/compass/compass.d.ts +21 -1
- package/dist/navigation-instruments/compass/compass.d.ts.map +1 -1
- package/dist/navigation-instruments/compass/compass.js +36 -16
- package/dist/navigation-instruments/compass/compass.js.map +1 -1
- package/dist/navigation-instruments/compass-sector/compass-sector.d.ts.map +1 -1
- package/dist/navigation-instruments/compass-sector/compass-sector.js.map +1 -1
- package/dist/navigation-instruments/gauge-radial/gauge-radial.css.js +34 -2
- package/dist/navigation-instruments/gauge-radial/gauge-radial.css.js.map +1 -1
- package/dist/navigation-instruments/gauge-radial/gauge-radial.d.ts +15 -0
- package/dist/navigation-instruments/gauge-radial/gauge-radial.d.ts.map +1 -1
- package/dist/navigation-instruments/gauge-radial/gauge-radial.js +35 -1
- package/dist/navigation-instruments/gauge-radial/gauge-radial.js.map +1 -1
- package/dist/navigation-instruments/heading/heading.d.ts +19 -1
- package/dist/navigation-instruments/heading/heading.d.ts.map +1 -1
- package/dist/navigation-instruments/heading/heading.js +34 -16
- package/dist/navigation-instruments/heading/heading.js.map +1 -1
- package/dist/navigation-instruments/rudder/rudder.d.ts +15 -2
- package/dist/navigation-instruments/rudder/rudder.d.ts.map +1 -1
- package/dist/navigation-instruments/rudder/rudder.js +36 -26
- package/dist/navigation-instruments/rudder/rudder.js.map +1 -1
- package/dist/navigation-instruments/speed-gauge/speed-gauge.d.ts +15 -1
- package/dist/navigation-instruments/speed-gauge/speed-gauge.d.ts.map +1 -1
- package/dist/navigation-instruments/speed-gauge/speed-gauge.js +31 -3
- package/dist/navigation-instruments/speed-gauge/speed-gauge.js.map +1 -1
- package/dist/navigation-instruments/watch/watch.d.ts +29 -1
- package/dist/navigation-instruments/watch/watch.d.ts.map +1 -1
- package/dist/navigation-instruments/watch/watch.js +52 -38
- package/dist/navigation-instruments/watch/watch.js.map +1 -1
- package/dist/svghelpers/radial-frame.d.ts +122 -0
- package/dist/svghelpers/radial-frame.d.ts.map +1 -0
- package/dist/svghelpers/radial-frame.js +259 -0
- package/dist/svghelpers/radial-frame.js.map +1 -0
- package/package.json +1 -1
- package/src/building-blocks/instrument-radial/instrument-radial.stories.ts +187 -6
- package/src/building-blocks/instrument-radial/instrument-radial.ts +98 -47
- package/src/navigation-instruments/azimuth-thruster/azimuth-thruster.stories.ts +78 -1
- package/src/navigation-instruments/azimuth-thruster/azimuth-thruster.ts +55 -11
- package/src/navigation-instruments/compass/compass.stories.ts +88 -1
- package/src/navigation-instruments/compass/compass.ts +52 -16
- package/src/navigation-instruments/compass-sector/compass-sector.ts +7 -0
- package/src/navigation-instruments/gauge-radial/gauge-radial.css +32 -2
- package/src/navigation-instruments/gauge-radial/gauge-radial.stories.ts +265 -1
- package/src/navigation-instruments/gauge-radial/gauge-radial.ts +59 -1
- package/src/navigation-instruments/heading/heading.stories.ts +92 -1
- package/src/navigation-instruments/heading/heading.ts +50 -16
- package/src/navigation-instruments/rudder/rudder.stories.ts +83 -1
- package/src/navigation-instruments/rudder/rudder.ts +61 -34
- package/src/navigation-instruments/speed-gauge/speed-gauge.stories.ts +94 -1
- package/src/navigation-instruments/speed-gauge/speed-gauge.ts +57 -4
- package/src/navigation-instruments/watch/watch.stories.ts +140 -1
- package/src/navigation-instruments/watch/watch.ts +84 -44
- package/src/storybook-util.ts +59 -0
- package/src/svghelpers/radial-frame.spec.ts +307 -0
- 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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
709
|
-
|
|
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
|
|
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
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
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
|
|
782
|
-
|
|
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 =
|
|
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 =
|
|
842
|
+
const northArrowEl = showNorthArrow
|
|
803
843
|
? renderNorthArrow({
|
|
804
844
|
scale,
|
|
805
845
|
rotation: this.rotation,
|
package/src/storybook-util.ts
CHANGED
|
@@ -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
|
+
});
|