@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.111 → 2.0.0-next.112

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 (55) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +73 -16
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +79 -7
  4. package/dist/navigation-instruments/compass/compass.d.ts +1 -1
  5. package/dist/navigation-instruments/compass/compass.d.ts.map +1 -1
  6. package/dist/navigation-instruments/compass/compass.js +1 -1
  7. package/dist/navigation-instruments/compass/compass.js.map +1 -1
  8. package/dist/navigation-instruments/current/current.d.ts +25 -0
  9. package/dist/navigation-instruments/current/current.d.ts.map +1 -1
  10. package/dist/navigation-instruments/current/current.js +25 -4
  11. package/dist/navigation-instruments/current/current.js.map +1 -1
  12. package/dist/navigation-instruments/heading/heading.js +1 -1
  13. package/dist/navigation-instruments/heading/heading.js.map +1 -1
  14. package/dist/navigation-instruments/pitch-roll-heave/pitch-roll-heave.d.ts +6 -0
  15. package/dist/navigation-instruments/pitch-roll-heave/pitch-roll-heave.d.ts.map +1 -1
  16. package/dist/navigation-instruments/pitch-roll-heave/pitch-roll-heave.js +8 -1
  17. package/dist/navigation-instruments/pitch-roll-heave/pitch-roll-heave.js.map +1 -1
  18. package/dist/navigation-instruments/rate-of-turn/rate-of-turn.d.ts +5 -2
  19. package/dist/navigation-instruments/rate-of-turn/rate-of-turn.d.ts.map +1 -1
  20. package/dist/navigation-instruments/rate-of-turn/rate-of-turn.js.map +1 -1
  21. package/dist/navigation-instruments/top-view-propulsion/top-view-propulsion.d.ts +6 -0
  22. package/dist/navigation-instruments/top-view-propulsion/top-view-propulsion.d.ts.map +1 -1
  23. package/dist/navigation-instruments/top-view-propulsion/top-view-propulsion.js +19 -4
  24. package/dist/navigation-instruments/top-view-propulsion/top-view-propulsion.js.map +1 -1
  25. package/dist/navigation-instruments/watch/environment.d.ts +8 -0
  26. package/dist/navigation-instruments/watch/environment.d.ts.map +1 -1
  27. package/dist/navigation-instruments/watch/environment.js +3 -0
  28. package/dist/navigation-instruments/watch/environment.js.map +1 -1
  29. package/dist/navigation-instruments/watch/force-pattern.d.ts +20 -2
  30. package/dist/navigation-instruments/watch/force-pattern.d.ts.map +1 -1
  31. package/dist/navigation-instruments/watch/force-pattern.js +12 -5
  32. package/dist/navigation-instruments/watch/force-pattern.js.map +1 -1
  33. package/dist/navigation-instruments/watch/watch.d.ts.map +1 -1
  34. package/dist/navigation-instruments/watch/watch.js +6 -4
  35. package/dist/navigation-instruments/watch/watch.js.map +1 -1
  36. package/dist/navigation-instruments/wind/wind.d.ts +5 -2
  37. package/dist/navigation-instruments/wind/wind.d.ts.map +1 -1
  38. package/dist/navigation-instruments/wind/wind.js +5 -1
  39. package/dist/navigation-instruments/wind/wind.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/navigation-instruments/compass/compass.ts +2 -2
  42. package/src/navigation-instruments/current/current.stories.ts +33 -0
  43. package/src/navigation-instruments/current/current.ts +37 -3
  44. package/src/navigation-instruments/heading/heading.stories.ts +6 -4
  45. package/src/navigation-instruments/heading/heading.ts +1 -1
  46. package/src/navigation-instruments/pitch-roll-heave/pitch-roll-heave.ts +8 -1
  47. package/src/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts +3 -3
  48. package/src/navigation-instruments/rate-of-turn/rate-of-turn.ts +5 -2
  49. package/src/navigation-instruments/top-view-propulsion/top-view-propulsion.ts +20 -2
  50. package/src/navigation-instruments/watch/environment.ts +10 -0
  51. package/src/navigation-instruments/watch/force-pattern.spec.ts +42 -0
  52. package/src/navigation-instruments/watch/force-pattern.ts +44 -7
  53. package/src/navigation-instruments/watch/watch.ts +20 -5
  54. package/src/navigation-instruments/wind/wind.spec.ts +3 -1
  55. package/src/navigation-instruments/wind/wind.ts +12 -3
@@ -18,8 +18,14 @@ export enum CurrentType {
18
18
 
19
19
  const CURRENT_PATTERN_SCALE_VESSEL = 1.39;
20
20
  const CURRENT_PATTERN_SCALE_DIRECTION = 2.0;
21
- /** Chevron-tip anchor radius for the peripheral icon (vessel type), from Figma. */
22
- const CURRENT_ICON_PERIPHERY_RADIUS = 116;
21
+ /**
22
+ * Chevron-tip anchor radius for the peripheral icon (vessel type): the tip
23
+ * sits on the track band's inner edge (112), so the 48-unit glyph box fills
24
+ * the band (112–160) exactly, per the OpenBridge 6.1 design.
25
+ */
26
+ const CURRENT_ICON_PERIPHERY_RADIUS = innerRingRadiusFor(
27
+ WatchCircleType.double
28
+ );
23
29
  /** Scale of the centered 24-unit chevron glyph (direction type), from Figma (168/24). */
24
30
  const CURRENT_DIRECTION_ICON_SCALE = 7;
25
31
 
@@ -56,6 +62,9 @@ export function clampCurrentSpeed(value: number | null): number | null {
56
62
  * the enhanced (blue) palette.
57
63
  * - **Vessel image**: configurable silhouette rotating with
58
64
  * `vesselHeadingDeg` (vessel type).
65
+ * - **Pattern toggle & wave tuning**: `hasPattern` switches the band pattern
66
+ * off; `waveLength`, `waveHeight` and `waveSpeed` tune the band spacing,
67
+ * intensity and drift motion.
59
68
  *
60
69
  * ## Usage Guidelines
61
70
  *
@@ -89,6 +98,28 @@ export class ObcCurrent extends LitElement {
89
98
  @property({type: Number}) currentFromDirection: number | null = null;
90
99
  /** Color priority: `Priority.enhanced` uses the blue/enhanced palette (default: `Priority.regular`). */
91
100
  @property({type: String}) priority: Priority = Priority.regular;
101
+ /**
102
+ * Show the force-graphics band pattern behind the watch face.
103
+ * @availableWhen currentFromDirection!=null
104
+ */
105
+ @property({type: Boolean, attribute: false}) hasPattern = true;
106
+ /**
107
+ * Wavelength of the pattern bands as a multiplier on the design spacing
108
+ * (1 = design, 0.5 = twice as dense).
109
+ * @availableWhen hasPattern==true && currentFromDirection!=null
110
+ */
111
+ @property({type: Number}) waveLength = 1;
112
+ /**
113
+ * Wave intensity: the peak opacity the pattern bands fade up to (0–1).
114
+ * @availableWhen hasPattern==true && currentFromDirection!=null
115
+ */
116
+ @property({type: Number}) waveHeight = 1;
117
+ /**
118
+ * Drift speed of the pattern in wavelengths per second, moving with the
119
+ * flow (negative drifts against it); 0 keeps the pattern static.
120
+ * @availableWhen hasPattern==true && currentFromDirection!=null
121
+ */
122
+ @property({type: Number}) waveSpeed = 0;
92
123
  /**
93
124
  * The image of the vessel.
94
125
  * @availableWhen type==vessel
@@ -148,7 +179,7 @@ export class ObcCurrent extends LitElement {
148
179
  }
149
180
 
150
181
  private renderForcePattern(watchCircleType: WatchCircleType) {
151
- if (this.currentFromDirection == null) {
182
+ if (!this.hasPattern || this.currentFromDirection == null) {
152
183
  return nothing;
153
184
  }
154
185
  return html`
@@ -162,6 +193,9 @@ export class ObcCurrent extends LitElement {
162
193
  color: this.enhanced
163
194
  ? 'var(--instrument-enhanced-tertiary-color)'
164
195
  : undefined,
196
+ waveLength: this.waveLength,
197
+ waveHeight: this.waveHeight,
198
+ waveSpeed: this.waveSpeed,
165
199
  })}
166
200
  </svg>
167
201
  `;
@@ -128,12 +128,14 @@ export const WithVectorArrows: Story = {
128
128
 
129
129
  /**
130
130
  * Below the label-reserve cap (~200px for the NSWE + north-arrow decor) the
131
- * frame reports `labelsHidden` and the instrument drops the labels and the
132
- * north arrow instead of letting them clip — the same graceful degradation
133
- * the tick-label instruments use (issue #1021).
131
+ * frame reports `labelsHidden` and the instrument drops the labels instead
132
+ * of letting them clip — the same graceful degradation the tick-label
133
+ * instruments use (issue #1021). The north arrow stays visible on even the
134
+ * smallest face: below the small-scale threshold it renders as a compact
135
+ * triangle at the ring that scales with the face, so it never clips.
134
136
  */
135
137
  export const SmallContainerDegraded: Story = {
136
- name: 'Small Container (160px, Labels + Arrow Hidden Past Cap)',
138
+ name: 'Small Container (160px, Labels Hidden Past Cap)',
137
139
  args: {
138
140
  width: 160,
139
141
  },
@@ -309,7 +309,7 @@ export class ObcHeading extends LitElement {
309
309
  .tickmarksInside=${this.tickmarksInside}
310
310
  .crosshairEnabled=${true}
311
311
  .crosshairCenterCutout=${this.hasCenterReadouts}
312
- .northArrow=${!frame.labelsHidden}
312
+ .northArrow=${true}
313
313
  .angleSetpoint=${this.headingSetpoint ?? undefined}
314
314
  .newAngleSetpoint=${this.newHeadingSetpoint}
315
315
  .atAngleSetpoint=${this._headingSp.computeAtSetpoint(this.heading)}
@@ -351,9 +351,16 @@ export class ObcPitchRollHeave extends LitElement {
351
351
  * `obc-watch` renders its crosshair after the bands, i.e. on top of them.
352
352
  * Drawing it here puts it under the watch layer, so the bands and the heave
353
353
  * column mask it naturally.
354
+ *
355
+ * In `single-scale` nothing frames the segment above the vessel (the other
356
+ * three arms end under the pitch band, roll band and heave column), so the
357
+ * vertical arm stops at the centre instead of continuing upward. In
358
+ * `dual-scale` the top roll band gives it a natural endpoint, so the full
359
+ * arm is kept.
354
360
  */
355
361
  private renderCrosshair(): SVGTemplateResult {
356
362
  const r = OUTER_RING_RADIUS;
363
+ const yTop = this.isDualScale ? -r : 0;
357
364
  return svg`
358
365
  <line
359
366
  x1=${-r} y1="0" x2=${r} y2="0"
@@ -361,7 +368,7 @@ export class ObcPitchRollHeave extends LitElement {
361
368
  vector-effect="non-scaling-stroke"
362
369
  />
363
370
  <line
364
- x1="0" y1=${-r} x2="0" y2=${r}
371
+ x1="0" y1=${yTop} x2="0" y2=${r}
365
372
  stroke="var(--instrument-frame-tertiary-color)"
366
373
  vector-effect="non-scaling-stroke"
367
374
  />
@@ -131,7 +131,7 @@ export const WithTrackBar: Story = {
131
131
  hasReadout: true,
132
132
  rateOfTurnDegreesPerMinute: 45,
133
133
  rotPosition: RotPosition.innerCircle,
134
- watchCircleType: WatchCircleType.double,
134
+ watchCircleType: WatchCircleType.triple,
135
135
  },
136
136
  };
137
137
 
@@ -142,7 +142,7 @@ export const WithTrackBarEnhanced: Story = {
142
142
  hasReadout: true,
143
143
  rateOfTurnDegreesPerMinute: 45,
144
144
  rotPosition: RotPosition.innerCircle,
145
- watchCircleType: WatchCircleType.double,
145
+ watchCircleType: WatchCircleType.triple,
146
146
  priority: Priority.enhanced,
147
147
  },
148
148
  };
@@ -154,7 +154,7 @@ export const WithTrackBarPortStarboard: Story = {
154
154
  hasReadout: true,
155
155
  rateOfTurnDegreesPerMinute: 45,
156
156
  rotPosition: RotPosition.innerCircle,
157
- watchCircleType: WatchCircleType.double,
157
+ watchCircleType: WatchCircleType.triple,
158
158
  rotPortStarboard: true,
159
159
  },
160
160
  };
@@ -35,7 +35,8 @@ export {RotType, RotPosition};
35
35
  * - **Track bar** (`hasTrackBar`): A bar in the ring band growing from the
36
36
  * twelve o'clock position to the measured rate of turn, with a needle
37
37
  * marker at its end and sector tickmarks (pairs with
38
- * `watchCircleType="double"` for the banded face).
38
+ * `watchCircleType="double"` for the banded face, or `"triple"` when the
39
+ * ROT dots sit on the inner circle).
39
40
  * - **Center readout** (`hasReadout`): A centered readout showing the
40
41
  * measured rate of turn (label `ROT`, unit `DEG/min` by default).
41
42
  * - **Color priority**: Uses `priority` to select regular or enhanced color
@@ -91,7 +92,9 @@ export class ObcRateOfTurn extends LitElement {
91
92
  * When `true`, shows a bar in the ring band from the twelve o'clock
92
93
  * position to the measured rate of turn, with a needle marker at its end
93
94
  * and sector tickmarks. Pairs with `watchCircleType="double"` for the
94
- * banded face. Driven by `rateOfTurnDegreesPerMinute` only.
95
+ * banded face or `"triple"` when `rotPosition` is `innerCircle`, so the
96
+ * dots get their inner track too. Driven by `rateOfTurnDegreesPerMinute`
97
+ * only.
95
98
  */
96
99
  @property({type: Boolean}) hasTrackBar: boolean = false;
97
100
  /**
@@ -15,10 +15,12 @@ import {SetpointBundle} from '../../svghelpers/setpoint-bundle.js';
15
15
  import '../watch/watch.js';
16
16
  import {
17
17
  OUTER_RING_RADIUS,
18
+ WatchCircleType,
18
19
  type WatchBarArea,
19
20
  type WatchNeedle,
20
21
  } from '../watch/watch.js';
21
22
  import {
23
+ BAND_INNER_RADIUS,
22
24
  LANE_DIVIDER_RADIUS,
23
25
  LANE_DIVIDER_WIDTH,
24
26
  PRIMARY_SUBBAND_INNER_RADIUS,
@@ -411,6 +413,21 @@ export class ObcTopViewPropulsion extends LitElement {
411
413
  ></path>`;
412
414
  }
413
415
 
416
+ /**
417
+ * Thin outline on the track band's inner edge, always shown per the
418
+ * design's track face. Drawn on the overlay because the watch paints its
419
+ * band bars over the ring circles, which would swallow a watch-side line.
420
+ */
421
+ private renderInnerRingLine() {
422
+ return svg`<circle
423
+ r=${BAND_INNER_RADIUS}
424
+ fill="none"
425
+ stroke="var(--instrument-frame-tertiary-color)"
426
+ stroke-width="1"
427
+ vector-effect="non-scaling-stroke"
428
+ ></circle>`;
429
+ }
430
+
414
431
  private renderPropeller() {
415
432
  return svg`<g transform="scale(${PROPELLER_SCALE}) translate(-80 -80)">${
416
433
  propellerImages[this.propeller]
@@ -448,6 +465,7 @@ export class ObcTopViewPropulsion extends LitElement {
448
465
  <obc-watch
449
466
  .touching=${this.touching}
450
467
  .arcFrame=${frame}
468
+ .watchCircleType=${WatchCircleType.double}
451
469
  .tickmarks=${shownTickmarks}
452
470
  .state=${this.state}
453
471
  .priority=${this.priority}
@@ -465,8 +483,8 @@ export class ObcTopViewPropulsion extends LitElement {
465
483
  .tickmarkStyle=${this.tickmarkStyle}
466
484
  ></obc-watch>
467
485
  <svg viewBox=${frame.viewBox} xmlns="http://www.w3.org/2000/svg">
468
- ${this.renderLoadingArc()} ${this.renderSecondaryArc()}
469
- ${this.renderPropeller()}
486
+ ${this.renderInnerRingLine()} ${this.renderLoadingArc()}
487
+ ${this.renderSecondaryArc()} ${this.renderPropeller()}
470
488
  </svg>
471
489
  </div>
472
490
  `;
@@ -70,6 +70,16 @@ const WIND_ICON_SCALE = 2;
70
70
  const WIND_ICON_TIP_X = 12;
71
71
  const WIND_ICON_TIP_Y = 22.4453;
72
72
 
73
+ /**
74
+ * Radial overhang of the rendered (2×, 48-unit) wind icon box past the tip
75
+ * anchor toward the watch center: the glyph tip sits at y=22.4453 of the
76
+ * 24-unit box, so the scaled box extends `(24 − 22.4453) × 2 ≈ 3.11` units
77
+ * inside the anchor radius. Add it to a desired box inner-edge radius to get
78
+ * the `windSymbolRadius` that makes the box start exactly there.
79
+ */
80
+ export const WIND_ICON_TIP_TO_BOX_INNER =
81
+ (24 - WIND_ICON_TIP_Y) * WIND_ICON_SCALE;
82
+
73
83
  const windIconCache = new Map<string, SVGTemplateResult>();
74
84
 
75
85
  function getWindIconSvg(bucket: number): SVGTemplateResult | null {
@@ -96,4 +96,46 @@ describe('renderCurrentForcePattern', () => {
96
96
  );
97
97
  expect(flat).toContain(String(-(256 * 1.39) / 2 + 104 * 1.39));
98
98
  });
99
+
100
+ it('honors wave tuning: length scales the tile, height caps opacity, speed drifts', () => {
101
+ const flat = flatten(
102
+ renderCurrentForcePattern({
103
+ fromDirectionDeg: 0,
104
+ radius: 112,
105
+ patternScale: 2.0,
106
+ waveLength: 0.75,
107
+ waveHeight: 0.6,
108
+ waveSpeed: 0.4,
109
+ })
110
+ );
111
+ expect(flat).toContain(`width=${48 * 2.0 * 0.75}`);
112
+ expect(flat).toContain('stop-opacity=0.6');
113
+ expect(flat).toContain('animateTransform');
114
+ expect(flat).toContain('dur="2.5s"');
115
+ });
116
+
117
+ it('is static at full intensity without wave options', () => {
118
+ const flat = flatten(
119
+ renderCurrentForcePattern({
120
+ fromDirectionDeg: 0,
121
+ radius: 112,
122
+ patternScale: 1.39,
123
+ })
124
+ );
125
+ expect(flat).not.toContain('animateTransform');
126
+ expect(flat).toContain('stop-opacity=1');
127
+ });
128
+
129
+ it('falls back to full intensity for a non-finite waveHeight', () => {
130
+ const flat = flatten(
131
+ renderCurrentForcePattern({
132
+ fromDirectionDeg: 0,
133
+ radius: 112,
134
+ patternScale: 1.39,
135
+ waveHeight: Number.NaN,
136
+ })
137
+ );
138
+ expect(flat).toContain('stop-opacity=1');
139
+ expect(flat).not.toContain('NaN');
140
+ });
99
141
  });
@@ -1,4 +1,4 @@
1
- import {svg, SVGTemplateResult} from 'lit';
1
+ import {nothing, svg, SVGTemplateResult} from 'lit';
2
2
 
3
3
  /**
4
4
  * Options for the force-graphics pattern underlays (wind streaks / current
@@ -19,6 +19,22 @@ export interface ForcePatternOptions {
19
19
  color?: string;
20
20
  }
21
21
 
22
+ /** Wave tuning for the current band pattern, on top of the shared options. */
23
+ export interface CurrentForcePatternOptions extends ForcePatternOptions {
24
+ /**
25
+ * Wavelength as a multiplier on the design band spacing (1 = design,
26
+ * 0.5 = twice as dense). Non-finite or non-positive values fall back to 1.
27
+ */
28
+ waveLength?: number;
29
+ /** Wave intensity: peak opacity the band gradient fades up to (0–1). */
30
+ waveHeight?: number;
31
+ /**
32
+ * Drift speed in wavelengths per second, moving with the flow (negative
33
+ * drifts against it). 0 / undefined keeps the pattern static.
34
+ */
35
+ waveSpeed?: number;
36
+ }
37
+
22
38
  const DESIGN_PATTERN_SQUARE = 256;
23
39
  const WIND_TILE = 24;
24
40
  /**
@@ -64,21 +80,39 @@ export function renderWindForcePattern(
64
80
  /**
65
81
  * Current force-graphics: soft-edged gradient bands (48-unit sawtooth,
66
82
  * transparent → opaque), perpendicular to the flow, clipped to a circle and
67
- * rotated by the from-direction.
83
+ * rotated by the from-direction. `waveLength` stretches the band spacing,
84
+ * `waveHeight` scales the gradient's peak opacity, and a non-zero `waveSpeed`
85
+ * drifts the field with the flow (one wavelength per `1/waveSpeed` seconds)
86
+ * via a declarative SMIL loop.
68
87
  */
69
88
  export function renderCurrentForcePattern(
70
- options: ForcePatternOptions
89
+ options: CurrentForcePatternOptions
71
90
  ): SVGTemplateResult {
72
91
  const {fromDirectionDeg, radius, patternScale} = options;
73
92
  const color = options.color ?? DEFAULT_COLOR;
74
- const tile = CURRENT_TILE * patternScale;
93
+ const lengthScale =
94
+ Number.isFinite(options.waveLength) && options.waveLength! > 0
95
+ ? options.waveLength!
96
+ : 1;
97
+ const intensity = Number.isFinite(options.waveHeight)
98
+ ? Math.max(0, Math.min(1, options.waveHeight!))
99
+ : 1;
100
+ const speed = Number.isFinite(options.waveSpeed) ? options.waveSpeed! : 0;
101
+ const tile = CURRENT_TILE * patternScale * lengthScale;
75
102
  const side = DESIGN_PATTERN_SQUARE * patternScale;
76
- const phase = CURRENT_TILE_PHASE_OFFSET * patternScale;
103
+ const phase = CURRENT_TILE_PHASE_OFFSET * patternScale * lengthScale;
104
+ // The pattern is anchored in the rect's user space, so translating the
105
+ // wrapper group drifts the whole band field; the rect is one tile taller on
106
+ // each end to stay covering the clip disc across the loop.
107
+ const drift =
108
+ speed !== 0
109
+ ? svg`<animateTransform attributeName="transform" attributeType="XML" type="translate" from="0 0" to="0 ${tile * Math.sign(speed)}" dur="${1 / Math.abs(speed)}s" repeatCount="indefinite"/>`
110
+ : nothing;
77
111
  return svg`
78
112
  <defs>
79
113
  <linearGradient id="current-force-band-gradient" x1="24" y1="0" x2="24" y2="48" gradientUnits="userSpaceOnUse">
80
114
  <stop stop-color=${color} stop-opacity="0"/>
81
- <stop offset="1" stop-color=${color}/>
115
+ <stop offset="1" stop-color=${color} stop-opacity=${intensity}/>
82
116
  </linearGradient>
83
117
  <pattern id="current-force-pattern" patternUnits="userSpaceOnUse" x=${-side / 2 + phase} y=${-side / 2 + phase} width=${tile} height=${tile} viewBox="0 0 48 48" preserveAspectRatio="none">
84
118
  <rect width="48" height="48" fill="url(#current-force-band-gradient)"/>
@@ -86,7 +120,10 @@ export function renderCurrentForcePattern(
86
120
  <clipPath id="current-force-clip"><circle cx="0" cy="0" r=${radius}/></clipPath>
87
121
  </defs>
88
122
  <g clip-path="url(#current-force-clip)" transform="rotate(${fromDirectionDeg})">
89
- <rect x=${-side / 2} y=${-side / 2} width=${side} height=${side} fill="url(#current-force-pattern)"/>
123
+ <g>
124
+ ${drift}
125
+ <rect x=${-side / 2} y=${-side / 2 - tile} width=${side} height=${side + 2 * tile} fill="url(#current-force-pattern)"/>
126
+ </g>
90
127
  </g>
91
128
  `;
92
129
  }
@@ -55,6 +55,7 @@ import {
55
55
  renderCurrent,
56
56
  renderCurrentCentered,
57
57
  renderWind,
58
+ WIND_ICON_TIP_TO_BOX_INNER,
58
59
  } from './environment.js';
59
60
  import {customElement} from '../../decorator.js';
60
61
  import {type ZoomToFitArcFrame} from '../../svghelpers/arc-frame.js';
@@ -130,6 +131,17 @@ export function innerRingRadiusFor(type: WatchCircleType): number {
130
131
 
131
132
  const RADIAL_SETPOINT_INWARD_ADJUST = 4;
132
133
 
134
+ /**
135
+ * Default anchors for the wind/current icons outside the outer ring (the
136
+ * compass layout): the 48-unit icon box sits 4 units off the ring, spanning
137
+ * 188–236 (OpenBridge 6.1 compass, node 19846-157200). The current chevron
138
+ * anchors by its tip at the box inner edge; the wind anchor adds the glyph's
139
+ * inward overhang so its box starts at the same edge.
140
+ */
141
+ const CURRENT_ICON_OUTSIDE_RADIUS = OUTER_RING_RADIUS + 4;
142
+ const WIND_ICON_OUTSIDE_RADIUS =
143
+ CURRENT_ICON_OUTSIDE_RADIUS + WIND_ICON_TIP_TO_BOX_INNER;
144
+
133
145
  /**
134
146
  * `<obc-watch>` - Core SVG renderer for circular/radial watch-based instruments.
135
147
  *
@@ -966,10 +978,13 @@ export class ObcWatch extends LitElement {
966
978
  : nothing;
967
979
 
968
980
  // Compute label positions once – used for both rendering and crosshair
969
- // knockout. NSWE labels and the north arrow are px-fixed outside decor,
970
- // so they follow the same labelsHidden degradation as tick label texts.
981
+ // knockout. NSWE labels are px-fixed outside decor, so they follow the
982
+ // same labelsHidden degradation as tick label texts. The north arrow is
983
+ // exempt: below the small-scale threshold it renders as a compact
984
+ // triangle at the ring that scales with the face, so it stays visible
985
+ // on the smallest faces without clipping.
971
986
  const showNsweLabels = this.showLabels && !this._labelsHidden;
972
- const showNorthArrow = this.northArrow && !this._labelsHidden;
987
+ const showNorthArrow = this.northArrow;
973
988
  const insideLabels = this.tickmarksInside && showNsweLabels;
974
989
  const includeNorth = !this.northArrow;
975
990
  const labelPositions = showNsweLabels
@@ -1006,7 +1021,7 @@ export class ObcWatch extends LitElement {
1006
1021
  ? svg`<g transform="scale(${this.scaleWindIcon})">${renderWind({
1007
1022
  windKnots: this.windKnots,
1008
1023
  fromDirectionDeg: this.windFromDirectionDeg,
1009
- radius: this.windSymbolRadius ?? 192,
1024
+ radius: this.windSymbolRadius ?? WIND_ICON_OUTSIDE_RADIUS,
1010
1025
  color: this.windColor,
1011
1026
  })}</g>`
1012
1027
  : nothing;
@@ -1022,7 +1037,7 @@ export class ObcWatch extends LitElement {
1022
1037
  : renderCurrent({
1023
1038
  current: this.current,
1024
1039
  fromDirectionDeg: this.currentFromDirectionDeg,
1025
- radius: this.currentSymbolRadius ?? 192,
1040
+ radius: this.currentSymbolRadius ?? CURRENT_ICON_OUTSIDE_RADIUS,
1026
1041
  color: this.currentColor,
1027
1042
  })
1028
1043
  : nothing;
@@ -3,8 +3,10 @@ import {resolveWindArrowPlacement, WindVariant} from './wind.js';
3
3
 
4
4
  describe('resolveWindArrowPlacement', () => {
5
5
  it('matches the OpenBridge 6.1 design values (effective tip = radius × scale)', () => {
6
+ // Large: 48-unit icon box fills the double-face track band (112–160);
7
+ // tip anchor = 112 + inward overhang 3.1094 of the 2× glyph.
6
8
  expect(resolveWindArrowPlacement(WindVariant.large)).toEqual({
7
- windSymbolRadius: 119,
9
+ windSymbolRadius: 112 + (24 - 22.4453) * 2,
8
10
  scaleWindIcon: 1.0,
9
11
  });
10
12
  expect(resolveWindArrowPlacement(WindVariant.medium)).toEqual({
@@ -7,6 +7,7 @@ import {
7
7
  WatchCircleType,
8
8
  innerRingRadiusFor,
9
9
  } from '../watch/watch.js';
10
+ import {WIND_ICON_TIP_TO_BOX_INNER} from '../watch/environment.js';
10
11
  import {renderWindForcePattern} from '../watch/force-pattern.js';
11
12
  import {Priority} from '../types.js';
12
13
  import {customElement} from '../../decorator.js';
@@ -320,8 +321,11 @@ function resolveHistogramMaxRadius(variant: ResolvedWindVariant): number {
320
321
  * `scaleWindIcon` scales the arrow glyph — and, because the scale group wraps
321
322
  * the translated glyph, the effective tip radius is `radius × scale`.
322
323
  *
323
- * Values measured from the OpenBridge 6.1 Figma (node 6552-102891):
324
- * large tip ≈119 (inner-ring edge), medium ≈93, small ≈8 (touching center).
324
+ * Large places the 48-unit icon box exactly inside the double-face track
325
+ * band (112–160), per the OpenBridge 6.1 design (the icon box spans the
326
+ * band, node 19663-140867); the tip anchor is the box inner edge plus the
327
+ * glyph's inward overhang. Medium ≈93 and small ≈8 (touching center) are
328
+ * measured from the Figma variants (node 6552-102891).
325
329
  */
326
330
  export function resolveWindArrowPlacement(variant: ResolvedWindVariant): {
327
331
  windSymbolRadius: number;
@@ -329,7 +333,12 @@ export function resolveWindArrowPlacement(variant: ResolvedWindVariant): {
329
333
  } {
330
334
  switch (variant) {
331
335
  case WindVariant.large:
332
- return {windSymbolRadius: 119, scaleWindIcon: 1.0};
336
+ return {
337
+ windSymbolRadius:
338
+ innerRingRadiusFor(WatchCircleType.double) +
339
+ WIND_ICON_TIP_TO_BOX_INNER,
340
+ scaleWindIcon: 1.0,
341
+ };
333
342
  case WindVariant.medium:
334
343
  return {windSymbolRadius: 62, scaleWindIcon: 1.5};
335
344
  case WindVariant.small: