diff --git a/.github/instructions/building-blocks.instructions.md b/.github/instructions/building-blocks.instructions.md index 25a70dc64..ef8e44a13 100644 --- a/.github/instructions/building-blocks.instructions.md +++ b/.github/instructions/building-blocks.instructions.md @@ -115,6 +115,23 @@ When working with building block components in this directory: - Height defines the visual size and coordinate system scale - Padding (top/bottom or left/right) is typically added outside the main drawing area +12. **`instrument-linear` shared helpers** (consumed by `obc-heave`, `obc-draft-trim`): + - `resolveLinearAdvice(advice, trendMin, trendMax)` resolves each advice zone's + hinted/regular/triggered state against a trend band. + - `verticalScaleTickmarks(range)` returns the tick configuration for a scale + spanning `±range` around a zero reference: a full-width line at zero, plus a + primary and a secondary ladder whose intervals step with the range — `1`/`0.5` + for a range of 5 or less, `5`/`1` above that. + - `linearTickInterval(height, range, minSpacing)` picks a 1-2-5 tick step by + on-screen spacing, for scales whose height is dictated by a surrounding layout. + - `watchfaceLinear`'s `options.maskId` overrides the container clip-mask id. + **Ids are document-scoped**, so any component rendering more than one gauge in + a single shadow root MUST give each a distinct id — otherwise every gauge + resolves to the first one's mask. + - **Scale space is positive-up.** A quantity measured downward (a draught) has to + be negated, and so do its advice bounds — which additionally swap, since + negating an interval reverses it: `{min, max}` → `{min: -max, max: -min}`. + ### `packages/openbridge-webcomponents/src/svghelpers/**` When working with SVG helper utilities: diff --git a/.github/instructions/watch-radial-instruments.instructions.md b/.github/instructions/watch-radial-instruments.instructions.md index da94be8ad..f8f830bbd 100644 --- a/.github/instructions/watch-radial-instruments.instructions.md +++ b/.github/instructions/watch-radial-instruments.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: "packages/openbridge-webcomponents/src/navigation-instruments/watch/**,packages/openbridge-webcomponents/src/navigation-instruments/compass/**,packages/openbridge-webcomponents/src/navigation-instruments/heading/**,packages/openbridge-webcomponents/src/navigation-instruments/rudder/**,packages/openbridge-webcomponents/src/navigation-instruments/wind/**,packages/openbridge-webcomponents/src/navigation-instruments/roll/**,packages/openbridge-webcomponents/src/navigation-instruments/speed-gauge/**,packages/openbridge-webcomponents/src/navigation-instruments/gauge-radial/**,packages/openbridge-webcomponents/src/navigation-instruments/rot-sector/**,packages/openbridge-webcomponents/src/navigation-instruments/azimuth-thruster/**,packages/openbridge-webcomponents/src/building-blocks/instrument-radial/**" +applyTo: "packages/openbridge-webcomponents/src/navigation-instruments/watch/**,packages/openbridge-webcomponents/src/navigation-instruments/compass/**,packages/openbridge-webcomponents/src/navigation-instruments/compass-sector/**,packages/openbridge-webcomponents/src/navigation-instruments/heading/**,packages/openbridge-webcomponents/src/navigation-instruments/rudder/**,packages/openbridge-webcomponents/src/navigation-instruments/wind/**,packages/openbridge-webcomponents/src/navigation-instruments/pitch/**,packages/openbridge-webcomponents/src/navigation-instruments/roll/**,packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll/**,packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll-heave/**,packages/openbridge-webcomponents/src/building-blocks/single-axis-inclinometer/**,packages/openbridge-webcomponents/src/navigation-instruments/speed-gauge/**,packages/openbridge-webcomponents/src/navigation-instruments/gauge-radial/**,packages/openbridge-webcomponents/src/navigation-instruments/rot-sector/**,packages/openbridge-webcomponents/src/navigation-instruments/rate-of-turn/**,packages/openbridge-webcomponents/src/navigation-instruments/course-arrows/**,packages/openbridge-webcomponents/src/navigation-instruments/readout/**,packages/openbridge-webcomponents/src/navigation-instruments/watch-flat/**,packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/**,packages/openbridge-webcomponents/src/navigation-instruments/rot-linear/**,packages/openbridge-webcomponents/src/navigation-instruments/azimuth-thruster/**,packages/openbridge-webcomponents/src/building-blocks/instrument-radial/**" --- # GitHub Copilot Custom Instructions @@ -96,6 +96,46 @@ When adding new features or fixing bugs: 3. Navigation instruments should only handle: property declarations, domain-specific overlays, and value-to-angle mapping 4. Avoid duplicating rendering logic across instruments +### Shared sibling modules + +- **`course-arrows/course-arrows.ts`** — HDG/COG arrow art + (`HdgArrowStyle`: arrowHead, needle, vector, beamLine; + `CogArrowStyle`: arrowHead, needle, vector, velocityVector), consumed by + compass, heading and — via the deprecated `compass/arrow.ts` shim, which + also carries the zoom `radiusOffset` — compass-sector. The new-style art is + generated from Figma into `course-arrows-art.ts`; do not hand-edit its path + data. +- **`readout/center-readout.ts`** — the center / below-strip readout cluster + (`renderCenterReadouts()`, `resolveCompassCenterReadouts()`, + `centerReadoutStyles`), consumed by compass, heading, compass-sector, + pitch-roll, pitch-roll-heave, rate-of-turn, compass-flat and rot-linear. It + reuses the existing `obc-readout` API only (closest match; no new typography + knobs). Two arrangements via `CenterReadoutArrangement`: + `primarySecondary` (default — first entry, one divider, then the rest side by + side) and `stacked` (a divider between every entry, used by + pitch-roll-heave's Pitch/Roll/Heave column). **The default must stay + `primarySecondary`** — every other consumer depends on it. +- **Arc tick ladder** — `tickmark.ts`'s `arcTickmarks()` emits the `secondary` + ladder that runs along an arc band (default interval 5°), + skipping the arc centre (which carries its own `main` mark) and the ends + (the rounded end cap already reads as a boundary). Shared by the + inclinometer family: `single-axis-inclinometer` (pitch, roll), `pitch-roll` + (both the full-watch and zoomed paths) and `pitch-roll-heave`. The zoomed + paths must pass the **clamped** half-extent, not the requested one, so ticks + never fall outside the band that is actually drawn. +- **`building-blocks/instrument-linear/instrument-linear.ts`** — + `linearTickInterval(height, range, minSpacing = 16)` picks a 1-2-5 tick step + whose on-screen spacing clears `minSpacing`, so a linear scale whose height + is dictated by a surrounding layout keeps a readable density. It reproduces + the intervals `obc-heave` hard-codes at its natural 336-unit height, which is + what fixes the constant; `obc-heave` itself still uses its literals so its + baselines do not move. +- **Track-bar recipe** — `obc-rate-of-turn`'s `hasTrackBar` composes existing + `obc-watch` inputs only: `barAreas` (band fill) + `needles` (marker at the + bar end) + sector `tickmarks` on the `double` face. `renderBars` applies + its area cut mask only when `areas` exist, so band bars render reliably on + full-circle faces. + --- ## Layering & Stacking Pattern @@ -418,19 +458,22 @@ Common instrument CSS variables used in `watch.ts` and helpers: ## Component Quick Reference -| Component | Uses | Key Features | -| ------------------- | --------------------- | ------------------------------------------------------- | -| `obc-watch` | Helper modules | Core renderer - ALL circular rendering logic | -| `instrument-radial` | `obc-watch` | Generic building block with configurable `getAngle()` | -| `compass` | `obc-watch` + overlay | Full compass: HDG/COG arrows, ROT, vessel, wind/current | -| `heading` | `obc-watch` + overlay | Simplified compass: HDG/COG arrows only | -| `rudder` | `obc-watch` + overlay | Half-circle: 40% top clipped, needle variant | -| `speed-gauge` | `obc-watch` + overlay | Speed arc: custom angle mapping, full needle | -| `wind` | `obc-watch` + overlay | Wind rose with histogram | -| `roll` | `obc-watch` | Roll indicator with vessel | -| `gauge-radial` | `instrument-radial` | Thin wrapper adding `enhanced` prop | -| `rot-sector` | `instrument-radial` | Rate of turn sector gauge | -| `azimuth-thruster` | `obc-watch` + overlay | Thruster with angle setpoint and thrust bar | +| Component | Uses | Key Features | +| ------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `obc-watch` | Helper modules | Core renderer - ALL circular rendering logic | +| `instrument-radial` | `obc-watch` | Generic building block with configurable `getAngle()` | +| `compass` | `obc-watch` + overlay | Full compass: HDG/COG arrow styles, ROT, vessel, wind/current, center readouts | +| `heading` | `obc-watch` + overlay | Simplified compass: HDG/COG arrow styles, optional vessel, center readouts | +| `rate-of-turn` | `obc-watch` | ROT dots/bar, track bar (barAreas+needles), center readout | +| `rudder` | `obc-watch` + overlay | Half-circle: 40% top clipped, needle variant | +| `speed-gauge` | `obc-watch` + overlay | Speed arc: custom angle mapping, full needle | +| `wind` | `obc-watch` + overlay | Wind rose with histogram | +| `pitch` / `roll` | `single-axis-inclinometer` → `obc-watch` | Single-axis inclinometer: side arc scale, single/dual scale, optional center readout | +| `pitch-roll` | `obc-watch` | Pitch + roll on one face; 4 arcs, optional zoomed sub-watches | +| `pitch-roll-heave` | `obc-watch` + `watchfaceLinear` | Pitch arc + roll arc + linear heave column in the band slot; single/dual scale, optional stacked readouts | +| `gauge-radial` | `instrument-radial` | Thin wrapper adding `enhanced` prop | +| `rot-sector` | `instrument-radial` | Rate of turn sector gauge | +| `azimuth-thruster` | `obc-watch` + overlay | Thruster with angle setpoint and thrust bar | --- @@ -527,7 +570,8 @@ All frame/viewBox geometry is centralized in `computeRadialFrame()`: `PADDING` comment there), pitch/roll/pitch-roll (labels inside ⇒ no reserve; the pitch-roll composite has a coupled `buildFrame` contract), wind-propulsion and velocity-projection-plot (explicit `padding` - override path, unchanged by design). + override path, unchanged by design). pitch-roll-heave follows pitch-roll + here: same `buildFrame` contract, same coupled zoom math. - Compass/heading's old empirical `72 + delta(clientSize)` padding was replaced by the analytic reserve (north arrow 16px always + NSWE labels 16px while `showLabels`); past the reserve cap both the @@ -560,13 +604,62 @@ Labels follow the design model with three placements: - **Internal** — labels inside the ring (`tickmarksInside`). - **Max-min** — labels at the arc ends (`endLabelsMaxMin`), e.g. the 180° gauge. -> **Validated combinations:** pitch/roll use `zoomToFitArc` + `shiftArcFrameToOuterEdge`; +> **Validated combinations:** pitch/roll/pitch-roll-heave use `zoomToFitArc` + `shiftArcFrameToOuterEdge`; > `gauge-radial` uses per-sector `clip*` and `endLabelsMaxMin` on the 180° sector. > Pairings like `clip*` + `zoomToFitArc` or `endLabelsMaxMin` + `zoomToFitArc` are not > currently validated. --- +## `obc-pitch-roll-heave` geometry contract + +The heave column is **not** independently sized — its dimensions fall out of the +arc geometry, and the whole layout is determined by the two half-extents +`θp` (`pitchArcAngle`, default 30) and `θr` (`rollArcAngle`, default 45): + +```text +column width = bandOuterRadius - bandInnerRadius (= band thickness, 72 un-zoomed) +column height = 2 · capRadius · sin(θp) (= 184 un-zoomed) +``` + +`capRadius` is the radius of the pitch arc's outer edge **about its own +origin** — un-zoomed that equals the outer ring, but under zoom the arc sits on +a shifted sub-watch, so the two differ. Measuring the height against it is what +keeps the column's top and bottom edges level with the pitch arc's end caps. + +Everything else follows: + +| Feature | Angular span | At the defaults | +| ------------------------------- | ---------------------------- | --------------- | +| pitch arc | `[90−θp, 90+θp]` | 60°–120° | +| roll arc | `[180−θr, 180+θr]` | 135°–225° | +| heave column at the ring radius | `[270−θp, 270+θp]` | 240°–300° | +| both diagonal gaps | `90 − θp − θr` each | 15° | +| complement ring | `[270+θp, 90−θp]` through 0° | 300°→60° | + +`θr` is clamped to `90 − θp` so the arcs cannot meet. **If you change the height +rule, the complement ring's endpoints move with it** — the ring starts exactly +where the column's top edge crosses the outer ring, which only coincides with +`270+θp` because of the `sin(θp)` term. + +Two clearance passes run **only** under zoom, after the frames are built and +left untouched (band thickness, position and zoom level are preserved): + +1. pitch vs roll at the diagonal — ratio-preserving bisection, identical to + `obc-pitch-roll`; +2. roll vs the heave column (`single-scale`) — shortens the roll arc alone, + using point-to-**rectangle** distance rather than the corner-to-corner + `signedDist` used between two arcs, because a corner can clear a rectangle + on one axis alone. + +`dual-scale` instead clamps the centred column's **height only** (never its +width, which reads as a match for the band thickness) to keep +`HEAVE_CENTRE_GAP_PX` clear of the surrounding band. All three rules are +no-ops at the un-zoomed defaults, which is what reproduces the design exactly — +verify that before retuning any of the constants. + +--- + ## Checklist for Adding New Features - [ ] Is the feature visual/rendering? → Add to `watch.ts` diff --git a/AGENTS.md b/AGENTS.md index 0a6678283..89e0554db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,6 +154,7 @@ Syntax: - **Enum / string inequality:** `@availableWhen state!=overlapped` — handy for "all values except one". - **Set membership:** `@availableWhen type in [LargeSideFlip, BottomFlip, TopFlip]` — use the **enum member identifier names**, not the string values. - **Non-empty string:** `@availableWhen label!=''` — for `string` props (default `''`) that gate another prop by being non-empty. +- **Empty / non-empty array:** `@availableWhen centerReadouts==[]` (available only while the array is empty) or `@availableWhen advices!=[]` — for `Array` props whose emptiness gates another prop. - **Defined / non-null:** `@availableWhen courseArrowPx!=undefined` (for `X | undefined`) or `@availableWhen headingSetpoint!=null` (for `X | null`). - **Combine:** join with `&&` (all required) or `||` (any sufficient). Always use `==`/`!=` (never a single `=`). @@ -216,7 +217,7 @@ Agents that support glob-scoped instructions should apply them automatically. | `circular-charts.instructions.md` | `bars-graphs/**`, `charthelpers/**` | Circular chart components (donut, pie, polar, radial-bar) | | `external-scale.instructions.md` | `external-scale/**`, `bar-vertical/**`, `bar-horizontal/**`, `gauge-vertical/**`, `gauge-horizontal/**` | External scale renderer and bar/gauge wrappers | | `line-area-charts.instructions.md` | `chart-line/**`, `line-graph/**`, `area-graph/**`, `gauge-trend/**` | Line/area charts and composite gauge-trend component | -| `watch-radial-instruments.instructions.md` | `watch/**`, `compass/**`, `heading/**`, `rudder/**`, `wind/**`, `roll/**`, `speed-gauge/**`, `gauge-radial/**`, `rot-sector/**`, `azimuth-thruster/**`, `instrument-radial/**` | Circular watch-based instruments and radial gauges | +| `watch-radial-instruments.instructions.md` | `watch/**`, `compass/**`, `compass-sector/**`, `heading/**`, `rudder/**`, `wind/**`, `roll/**`, `pitch-roll/**`, `speed-gauge/**`, `gauge-radial/**`, `rot-sector/**`, `rate-of-turn/**`, `course-arrows/**`, `readout/**`, `watch-flat/**`, `compass-flat/**`, `rot-linear/**`, `azimuth-thruster/**`, `instrument-radial/**` | Watch-based instruments (radial core + linear strip counterparts), radial gauges, shared arrow/readout modules | | `setpoint.instructions.md` | `svghelpers/setpoint*.ts`, `building-blocks/setpoint/**` | Setpoint design layer, mixin/bundle, confirm animation | | `automation-components.instructions.md` | `automation/**` | Automation devices, valves, lines, tanks, badges | | `ui-components.instructions.md` | `components/**` | General UI components (buttons, cards, inputs, feedback) | @@ -371,7 +372,7 @@ Required modifications after pasting: 3. **Respect glob-scoped instructions** (§ 4) — read the matching `.instructions.md` file when touching files in its scope. 4. **Accessibility is required for interactive components.** Every new or modified component in `src/components/**` or `src/automation/**` must support full keyboard navigation and meet WCAG 2.1 AA. Read [`.github/instructions/a11y.instructions.md`](.github/instructions/a11y.instructions.md) for the activation-key table, ARIA rules, focus handling, and testing checklist before writing or changing an interactive component. 5. **Do not edit auto-generated packages** (`-react`, `-vue`, `-ng`, `-svelte`). Run `npm run wrappers` instead. -6. **Run `npm run analyze`** after adding or renaming a `@customElement` to keep `custom-elements.json` in sync. +6. **Run `npm run analyze`** after adding or renaming a `@customElement` to keep `custom-elements.json` in sync. Storybook resolves story args to element properties through the manifest, so run it **before** testing the stories of a newly created component — without it the args silently never reach the element. Never hand-edit `custom-elements.json` — it is auto-generated and git-ignored. Fix manifest inaccuracies at the source (`@slot`/`@fires`/property JSDoc); see § 3 "Slots and events are manifest-critical" and run `npm run lint:slots`. 7. **Run `npm run lint`** after code changes to catch issues early. 8. **Insert `TODO(designer)`** for any documentation detail whose purpose is unclear from code alone. @@ -381,9 +382,9 @@ Required modifications after pasting: ```bash npx vitest run --project storybook 'component-name' ``` -12. **Update baselines for a single component:** +12. **Update baselines for a single component** — the filter must come **before** `--update`; written after the flag, the name is consumed as the flag's value and the FULL suite runs in update mode, silently rewriting unrelated flaky baselines: ```bash - npx vitest run --project storybook --update 'component-name' + npx vitest run --project storybook 'component-name' --update ``` 13. **Always verify after updating baselines** — re-run the test without `--update` to confirm the new baselines are stable: ```bash diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/ar/chart-object-vessel-button/chart-object-vessel-button.stories.ts/cross-line-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/ar/chart-object-vessel-button/chart-object-vessel-button.stories.ts/cross-line-auto.png index d7b0d343b..5903fd11c 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/ar/chart-object-vessel-button/chart-object-vessel-button.stories.ts/cross-line-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/ar/chart-object-vessel-button/chart-object-vessel-button.stories.ts/cross-line-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass-flat/compass-flat.stories.ts/with-readout-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass-flat/compass-flat.stories.ts/with-readout-auto.png new file mode 100644 index 000000000..df522ab9b Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass-flat/compass-flat.stories.ts/with-readout-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/map-graphics-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/map-graphics-auto.png new file mode 100644 index 000000000..cc24936fc Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/map-graphics-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-center-readout-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-center-readout-auto.png new file mode 100644 index 000000000..636c992b7 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-center-readout-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-cog-arrow-styles-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-cog-arrow-styles-auto.png new file mode 100644 index 000000000..2e526a117 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-cog-arrow-styles-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-hdg-arrow-styles-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-hdg-arrow-styles-auto.png new file mode 100644 index 000000000..31160d39b Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-hdg-arrow-styles-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-primary-secondary-readouts-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-primary-secondary-readouts-auto.png new file mode 100644 index 000000000..1e4d8011d Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-primary-secondary-readouts-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-three-readouts-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-three-readouts-auto.png new file mode 100644 index 000000000..b72d56a9f Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-three-readouts-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-two-primary-readouts-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-two-primary-readouts-auto.png new file mode 100644 index 000000000..b461c0e87 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/compass/compass.stories.ts/with-two-primary-readouts-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/advice-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/advice-auto.png new file mode 100644 index 000000000..739d9a8a0 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/advice-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/default-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/default-auto.png new file mode 100644 index 000000000..672cc190c Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/default-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/enhanced-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/enhanced-auto.png new file mode 100644 index 000000000..55c324980 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/enhanced-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/even-keel-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/even-keel-auto.png new file mode 100644 index 000000000..57a7f12c7 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/even-keel-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/trim-by-bow-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/trim-by-bow-auto.png new file mode 100644 index 000000000..13649df9d Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/draft-trim/draft-trim.stories.ts/trim-by-bow-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heading/heading.stories.ts/with-center-readout-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heading/heading.stories.ts/with-center-readout-auto.png new file mode 100644 index 000000000..e7027d728 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heading/heading.stories.ts/with-center-readout-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heading/heading.stories.ts/with-vector-arrows-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heading/heading.stories.ts/with-vector-arrows-auto.png new file mode 100644 index 000000000..d32f7e24b Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heading/heading.stories.ts/with-vector-arrows-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heading/heading.stories.ts/with-vessel-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heading/heading.stories.ts/with-vessel-auto.png new file mode 100644 index 000000000..72538dfbf Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heading/heading.stories.ts/with-vessel-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heave/heave.stories.ts/bar-advice-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heave/heave.stories.ts/bar-advice-auto.png new file mode 100644 index 000000000..e9727d7a0 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heave/heave.stories.ts/bar-advice-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heave/heave.stories.ts/bar-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heave/heave.stories.ts/bar-auto.png new file mode 100644 index 000000000..ab5475649 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heave/heave.stories.ts/bar-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heave/heave.stories.ts/bar-enhanced-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heave/heave.stories.ts/bar-enhanced-auto.png new file mode 100644 index 000000000..d7499e0e3 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/heave/heave.stories.ts/bar-enhanced-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/advice-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/advice-auto.png new file mode 100644 index 000000000..a23cc3087 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/advice-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/dual-scale-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/dual-scale-auto.png new file mode 100644 index 000000000..8c10f396e Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/dual-scale-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/dual-scale-enhanced-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/dual-scale-enhanced-auto.png new file mode 100644 index 000000000..7d0775114 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/dual-scale-enhanced-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/enhanced-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/enhanced-auto.png new file mode 100644 index 000000000..793fa839d Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/enhanced-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/primary-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/primary-auto.png new file mode 100644 index 000000000..a002304e9 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/primary-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/with-readout-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/with-readout-auto.png new file mode 100644 index 000000000..24513cd72 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/with-readout-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/with-readout-enhanced-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/with-readout-enhanced-auto.png new file mode 100644 index 000000000..811d4b74c Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/with-readout-enhanced-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/zoomed-in-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/zoomed-in-auto.png new file mode 100644 index 000000000..e6e0213ec Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/zoomed-in-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/zoomed-in-dual-scale-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/zoomed-in-dual-scale-auto.png new file mode 100644 index 000000000..3edc72ddd Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/zoomed-in-dual-scale-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/zoomed-in-narrow-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/zoomed-in-narrow-auto.png new file mode 100644 index 000000000..62a55af7d Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts/zoomed-in-narrow-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/primary-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/primary-auto.png index 334ac9134..662362428 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/primary-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/primary-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/rov-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/rov-auto.png index 15e4b41a3..0503d3c16 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/rov-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/rov-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/scaled-fore-image-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/scaled-fore-image-auto.png index 1be7881a8..2046a8858 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/scaled-fore-image-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/scaled-fore-image-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/with-readout-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/with-readout-auto.png index d1d453878..4b94e4693 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/with-readout-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/with-readout-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-auto.png index ececd497e..84da2d25b 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-narrow-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-narrow-auto.png index 2fb7522f3..193cff95d 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-narrow-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-narrow-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-rectangular-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-rectangular-auto.png index e36c06540..63fca8c39 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-rectangular-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-rectangular-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-with-readout-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-with-readout-auto.png index 3cff3adbe..3eb7016b2 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-with-readout-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch-roll/pitch-roll.stories.ts/zoomed-in-with-readout-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/dual-scale-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/dual-scale-auto.png index 0acee1e44..3be8f2fc3 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/dual-scale-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/dual-scale-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/enhanced-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/enhanced-auto.png index f8718bb75..84486b6fd 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/enhanced-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/enhanced-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/primary-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/primary-auto.png index 1dd5c77f3..46754edd0 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/primary-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/primary-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/with-readout-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/with-readout-auto.png index 19fe98ea3..19be156ac 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/with-readout-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/with-readout-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/zoomed-in-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/zoomed-in-auto.png index 9a303a332..70b53bfd6 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/zoomed-in-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/zoomed-in-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/zoomed-in-narrow-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/zoomed-in-narrow-auto.png index 43282eda7..9658a58ee 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/zoomed-in-narrow-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/pitch/pitch.stories.ts/zoomed-in-narrow-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-readout-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-readout-auto.png new file mode 100644 index 000000000..c3468da87 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-readout-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-track-bar-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-track-bar-auto.png new file mode 100644 index 000000000..ddf849dc1 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-track-bar-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-track-bar-enhanced-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-track-bar-enhanced-auto.png new file mode 100644 index 000000000..ebf2b6617 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-track-bar-enhanced-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-track-bar-port-starboard-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-track-bar-port-starboard-auto.png new file mode 100644 index 000000000..2e10b1193 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts/with-track-bar-port-starboard-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/dual-scale-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/dual-scale-auto.png index 5caff2991..349d8f011 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/dual-scale-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/dual-scale-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/enhanced-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/enhanced-auto.png index b2407e1db..65487bf05 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/enhanced-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/enhanced-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/primary-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/primary-auto.png index 4c7d4c45e..714c6c3ba 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/primary-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/primary-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/rov-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/rov-auto.png index a71f07ef2..f38f05884 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/rov-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/rov-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/with-readout-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/with-readout-auto.png index 7711f2281..5794d7d60 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/with-readout-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/with-readout-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/zoomed-in-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/zoomed-in-auto.png index 40473e639..ddfb7b6fc 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/zoomed-in-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/zoomed-in-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/zoomed-in-narrow-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/zoomed-in-narrow-auto.png index c3844b6d9..0a2f54791 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/zoomed-in-narrow-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/roll/roll.stories.ts/zoomed-in-narrow-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/default-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/default-auto.png new file mode 100644 index 000000000..024f4d96c Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/default-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/enhanced-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/enhanced-auto.png new file mode 100644 index 000000000..48faaa17a Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/enhanced-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/port-starboard-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/port-starboard-auto.png new file mode 100644 index 000000000..f33dc3d67 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/port-starboard-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/port-starboard-port-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/port-starboard-port-auto.png new file mode 100644 index 000000000..7873c996a Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/port-starboard-port-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/with-readout-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/with-readout-auto.png new file mode 100644 index 000000000..78a4f6740 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/with-readout-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/with-readout-enhanced-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/with-readout-enhanced-auto.png new file mode 100644 index 000000000..b510b6f57 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/rot-linear/rot-linear.stories.ts/with-readout-enhanced-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-arrows-compass-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-arrows-compass-auto.png new file mode 100644 index 000000000..4c27ea6da Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-arrows-compass-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-arrows-framed-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-arrows-framed-auto.png new file mode 100644 index 000000000..33a3bc157 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-arrows-framed-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-arrows-standalone-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-arrows-standalone-auto.png new file mode 100644 index 000000000..76c63521e Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-arrows-standalone-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-bars-compass-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-bars-compass-auto.png new file mode 100644 index 000000000..ea063b870 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-bars-compass-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-bars-framed-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-bars-framed-auto.png new file mode 100644 index 000000000..da626f922 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-bars-framed-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-bars-standalone-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-bars-standalone-auto.png new file mode 100644 index 000000000..6dc819eb6 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/along-athwart-bars-standalone-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/bars-max-clamped-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/bars-max-clamped-auto.png new file mode 100644 index 000000000..b0ba2f5d9 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/bars-max-clamped-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/long-lat-arrows-compass-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/long-lat-arrows-compass-auto.png new file mode 100644 index 000000000..cfef545c0 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/long-lat-arrows-compass-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/long-lat-arrows-framed-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/long-lat-arrows-framed-auto.png new file mode 100644 index 000000000..53986ec3a Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/long-lat-arrows-framed-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/long-lat-arrows-standalone-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/long-lat-arrows-standalone-auto.png new file mode 100644 index 000000000..642fbaa2c Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/long-lat-arrows-standalone-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/negative-speeds-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/negative-speeds-auto.png new file mode 100644 index 000000000..6eeb5ae86 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/negative-speeds-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/partial-axes--undefined-hidden--auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/partial-axes--undefined-hidden--auto.png new file mode 100644 index 000000000..9804c6904 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/partial-axes--undefined-hidden--auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/zero-speed-tinted-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/zero-speed-tinted-auto.png new file mode 100644 index 000000000..2174b1775 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/speed-directions/speed-directions.stories.ts/zero-speed-tinted-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-at-setpoint-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-at-setpoint-auto.png new file mode 100644 index 000000000..f13c7c341 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-at-setpoint-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-negative-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-negative-auto.png new file mode 100644 index 000000000..7ce09b1ed Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-negative-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-off-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-off-auto.png new file mode 100644 index 000000000..5acb91bc0 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-off-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-regular-priority-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-regular-priority-auto.png new file mode 100644 index 000000000..048f1412b Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/pitch-rpm-regular-priority-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-at-setpoint-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-at-setpoint-auto.png new file mode 100644 index 000000000..ad9991a25 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-at-setpoint-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-input-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-input-auto.png new file mode 100644 index 000000000..64c8a11c6 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-input-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-loading-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-loading-auto.png new file mode 100644 index 000000000..5cb10d9ff Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-loading-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-negative-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-negative-auto.png new file mode 100644 index 000000000..5e17a6ab5 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-negative-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-not-at-setpoint-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-not-at-setpoint-auto.png new file mode 100644 index 000000000..3da9cfae7 Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-not-at-setpoint-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-off-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-off-auto.png new file mode 100644 index 000000000..06ed9c9ad Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-off-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-ready-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-ready-auto.png new file mode 100644 index 000000000..9e27a0d6b Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-ready-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-regular-priority-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-regular-priority-auto.png new file mode 100644 index 000000000..41aa6b21e Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/power-regular-priority-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/propellers-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/propellers-auto.png new file mode 100644 index 000000000..b7eaf67ac Binary files /dev/null and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts/propellers-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/auto-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/auto-auto.png index 65e5cfb7e..d383615e0 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/auto-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/auto-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/large-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/large-auto.png index 73f08d3a4..82b86bdfc 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/large-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/large-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/medium-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/medium-auto.png index a33dd4d0f..20b3a297d 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/medium-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/medium-auto.png differ diff --git a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/primary-auto.png b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/primary-auto.png index 73f08d3a4..82b86bdfc 100644 Binary files a/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/primary-auto.png and b/packages/openbridge-webcomponents/__vis__/linux/__baselines__/navigation-instruments/wind/wind.stories.ts/primary-auto.png differ diff --git a/packages/openbridge-webcomponents/src/building-blocks/instrument-linear/advice.ts b/packages/openbridge-webcomponents/src/building-blocks/instrument-linear/advice.ts index a24018e87..73c15fca0 100644 --- a/packages/openbridge-webcomponents/src/building-blocks/instrument-linear/advice.ts +++ b/packages/openbridge-webcomponents/src/building-blocks/instrument-linear/advice.ts @@ -23,6 +23,30 @@ export interface LinearAdvice { hinted: boolean; } +/** + * Resolves the display state of each advice zone against a trend band: an + * advice the band reaches into is `triggered`, otherwise it is `hinted` or + * `regular` as configured. + * + * Shared by the vertical draught-style scales (`obc-heave`, `obc-draft-trim`), + * which each evaluate their own trend band against the same advice list. + */ +export function resolveLinearAdvice( + advice: LinearAdvice[], + trendMin: number, + trendMax: number +): LinearAdviceRaw[] { + return advice.map((a) => { + const isActive = trendMax >= a.min && trendMin <= a.max; + const state = isActive + ? AdviceState.triggered + : a.hinted + ? AdviceState.hinted + : AdviceState.regular; + return {...a, min: a.min, max: a.max, state} satisfies LinearAdviceRaw; + }); +} + function adviceMask({ height, minValue, diff --git a/packages/openbridge-webcomponents/src/building-blocks/instrument-linear/instrument-linear.spec.ts b/packages/openbridge-webcomponents/src/building-blocks/instrument-linear/instrument-linear.spec.ts new file mode 100644 index 000000000..461b59eaf --- /dev/null +++ b/packages/openbridge-webcomponents/src/building-blocks/instrument-linear/instrument-linear.spec.ts @@ -0,0 +1,45 @@ +import {describe, it, expect} from 'vitest'; +import {linearTickInterval} from './instrument-linear.js'; + +describe('linearTickInterval', () => { + // The default minSpacing of 16 is load-bearing: it is chosen so the helper + // independently reproduces the intervals `obc-heave` hard-codes at its + // natural 336-unit height, while giving the shorter 184-unit gauge inside + // `obc-pitch-roll-heave` the sparser ladder the design calls for. + it('reproduces the obc-heave secondary intervals at its natural height', () => { + expect(linearTickInterval(336, 10)).toBe(1); + expect(linearTickInterval(336, 5)).toBe(0.5); + }); + + it('thins the ladder on the compact pitch-roll-heave gauge', () => { + expect(linearTickInterval(184, 10)).toBe(2); + expect(linearTickInterval(184, 5)).toBe(1); + }); + + it('never returns a step whose spacing falls below minSpacing', () => { + for (const height of [64, 92, 120, 184, 240, 336, 512]) { + for (const range of [0.5, 1, 2, 5, 10, 25, 100]) { + const step = linearTickInterval(height, range); + expect(step).toBeGreaterThan(0); + expect((step * height) / (2 * range)).toBeGreaterThanOrEqual(16); + } + } + }); + + it('picks the smallest qualifying step on the 1-2-5 ladder', () => { + for (const height of [64, 184, 336]) { + for (const range of [1, 5, 10, 60]) { + const step = linearTickInterval(height, range); + const smaller = step / (step.toString().startsWith('5') ? 2.5 : 2); + expect((smaller * height) / (2 * range)).toBeLessThan(16); + } + } + }); + + it('returns 0 for degenerate inputs', () => { + expect(linearTickInterval(0, 10)).toBe(0); + expect(linearTickInterval(184, 0)).toBe(0); + expect(linearTickInterval(Number.NaN, 10)).toBe(0); + expect(linearTickInterval(184, Number.POSITIVE_INFINITY)).toBe(0); + }); +}); diff --git a/packages/openbridge-webcomponents/src/building-blocks/instrument-linear/instrument-linear.ts b/packages/openbridge-webcomponents/src/building-blocks/instrument-linear/instrument-linear.ts index ee4de51ed..c504d2b8d 100644 --- a/packages/openbridge-webcomponents/src/building-blocks/instrument-linear/instrument-linear.ts +++ b/packages/openbridge-webcomponents/src/building-blocks/instrument-linear/instrument-linear.ts @@ -63,6 +63,12 @@ export function watchfaceLinear( hideContainer: boolean; off: boolean; priority: Priority; + /** + * Id for the container clip mask. Only needs setting when a document holds + * more than one gauge — ids are document-scoped, so two gauges in the same + * shadow root would otherwise both resolve to the first one's mask. + */ + maskId?: string; }, tickmarks: { /** Array of values where full-width main tickmarks are drawn. */ @@ -102,7 +108,7 @@ export function watchfaceLinear( ); const tickmarksSvg: SVGTemplateResult[] = []; - const maskId = 'boxMask'; + const maskId = options.maskId ?? 'boxMask'; const mask = options.hideContainer ? nothing : svg` @@ -193,6 +199,67 @@ export function watchfaceLinear( return all; } +/** + * Tickmark configuration for a vertical draught-style scale spanning `±range` + * around a zero reference line: a full-width line at zero, plus a primary and + * a secondary ladder whose intervals step with the range — `1` / `0.5` for a + * range of 5 or less, `5` / `1` above that. + * + * Shared by `obc-heave` and `obc-draft-trim`, which render the same scale + * against different references (the heave datum, the waterline). + */ +export function verticalScaleTickmarks(range: number): { + mainTickmarks: number[]; + primaryTickmarkInterval: number; + secondaryTickmarkInterval: number; +} { + return { + mainTickmarks: [0], + primaryTickmarkInterval: range <= 5 ? 1 : 5, + secondaryTickmarkInterval: range <= 5 ? 0.5 : 1, + }; +} + +/** + * Smallest "nice" tick step (1-2-5 decade ladder) whose on-screen spacing on a + * `height`-tall, `±range` gauge is at least `minSpacing` SVG units. + * + * Lets a linear scale keep a readable tick density when its height is dictated + * by a surrounding layout rather than chosen for the scale. The default + * `minSpacing` of 16 reproduces the intervals `obc-heave` uses at its natural + * 336-unit height (1 at ±10, 0.5 at ±5) and matches the ~15-unit spacing of the + * 5° ladder on the radial inclinometer arcs, so a linear and a radial scale + * read at the same density side by side. + * + * Returns 0 when the inputs cannot produce a ladder, which the tickmark + * generators treat as "no ticks". + */ +export function linearTickInterval( + height: number, + range: number, + minSpacing = 16 +): number { + if ( + !Number.isFinite(height) || + !Number.isFinite(range) || + !Number.isFinite(minSpacing) || + height <= 0 || + range <= 0 || + minSpacing <= 0 + ) { + return 0; + } + const unitsPerValue = height / (2 * range); + const needed = minSpacing / unitsPerValue; + const decade = Math.pow(10, Math.floor(Math.log10(needed))); + for (const step of [1, 2, 5, 10]) { + if (decade * step >= needed) { + return decade * step; + } + } + return decade * 10; +} + export function valueToY( value: number, minValue: number, diff --git a/packages/openbridge-webcomponents/src/building-blocks/single-axis-inclinometer/single-axis-inclinometer.ts b/packages/openbridge-webcomponents/src/building-blocks/single-axis-inclinometer/single-axis-inclinometer.ts index a4856a293..fe4b9f7a1 100644 --- a/packages/openbridge-webcomponents/src/building-blocks/single-axis-inclinometer/single-axis-inclinometer.ts +++ b/packages/openbridge-webcomponents/src/building-blocks/single-axis-inclinometer/single-axis-inclinometer.ts @@ -10,7 +10,10 @@ import { } from '../../navigation-instruments/watch/watch.js'; import {renderInstrumentReadout} from '../../navigation-instruments/readout/instrument-readout.js'; import {Priority} from '../../navigation-instruments/types.js'; -import {TickmarkType} from '../../navigation-instruments/watch/tickmark.js'; +import { + arcTickmarks, + TickmarkType, +} from '../../navigation-instruments/watch/tickmark.js'; import { AdviceState, AdviceType, @@ -233,6 +236,7 @@ export class SingleAxisInclinometer extends LitElement { // keeps the zoomed `arcFrame` correct. protected renderScale(areas: WatchArea[], opposite: boolean) { const centerAngle = this.centerAngle; + const arcAngle = normalizeArcAngle(this.arcAngle, 45); return html` `; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.css b/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.css index 8750e1d90..cf5d44dd3 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.css +++ b/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.css @@ -19,6 +19,29 @@ :host { display: grid; place-items: center; + grid-template-rows: 100%; + grid-template-columns: 100%; width: 100%; height: 100%; } + +.with-readout { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + min-height: 0; +} + +.with-readout .container { + flex: 0 1 auto; + min-height: 0; + height: auto; + aspect-ratio: 3 / 1; +} + +.flat-readout { + flex: 0 0 auto; +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.stories.ts b/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.stories.ts index 203bb8e87..45f5cb669 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.stories.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.stories.ts @@ -56,6 +56,10 @@ const meta: Meta = { }, rotMaxValue: {control: {type: 'range', min: 1, max: 60, step: 1}}, rotArcExtent: {control: {type: 'range', min: 10, max: 180, step: 5}}, + hasReadout: {control: 'boolean'}, + label: {control: 'text'}, + unit: {control: 'text'}, + fractionDigits: {control: 'number'}, }, decorators: [widthDecorator], } satisfies Meta; @@ -65,6 +69,13 @@ type Story = StoryObj; export const Primary: Story = {}; +export const WithReadout: Story = { + tags: ['6.1'], + args: { + hasReadout: true, + }, +}; + export const WithFOVIndicator: Story = { args: { FOVIndicator: true, diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.ts b/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.ts index f6e4d098f..3405b668d 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/compass-flat/compass-flat.ts @@ -1,5 +1,11 @@ import {LitElement, html, svg, SVGTemplateResult, unsafeCSS} from 'lit'; import componentStyle from './compass-flat.css?inline'; +import instrumentReadoutStyle from '../readout/instrument-readout.css?inline'; +import { + centerReadoutStyles, + renderCenterReadouts, +} from '../readout/center-readout.js'; +import {ReadoutSize} from '../readout/readout.js'; import {property, state} from 'lit/decorators.js'; import {Tickmark, TickmarkType} from '../watch-flat/tickmark-flat.js'; import '../watch-flat/watch-flat.js'; @@ -55,6 +61,8 @@ export interface Label { * - **Rate of turn**: Animated linear ROT indicator (spinning dots or * horizontal bar) via `rotType`. When enabled, a bottom bar is added to * the strip to house the indicator. + * - **Readout**: `hasReadout` shows a centered heading readout below the + * strip (label `HDG`, unit `DEG` by default). * - **Color priority**: Per-element priority for HDG, COG, and ROT via * `priorityElements`. * @@ -113,21 +121,57 @@ export class ObcCompassFlat extends LitElement { @property({type: Boolean}) rotPortStarboard: boolean = false; /** @availableWhen rotType!=undefined */ @property({type: Number}) rotAtZeroDeadband: number = ROT_ZERO_DEADBAND_DEG; + /** + * When `true`, shows a centered `` below the strip displaying + * the heading (label `HDG`, unit `DEG`). The value color follows the HDG + * entry in `priorityElements`. + */ + @property({type: Boolean}) hasReadout: boolean = false; + /** + * Readout label. Default `HDG`. + * @availableWhen hasReadout==true + */ + @property({type: String}) label = 'HDG'; + /** + * Readout unit. Default `DEG`. + * @availableWhen hasReadout==true + */ + @property({type: String}) unit = 'DEG'; + /** + * Number of fraction digits shown in the readout. Default `0`. + * @availableWhen hasReadout==true + */ + @property({type: Number}) fractionDigits = 0; - @state() private containerWidth = 0; - @state() private maxContainerWidth = 0; + @state() private hostWidth = 0; + @state() private hostHeight = 0; private resizeObserver: ResizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { - // Made by chatGPT so that the text is inside the wrapper - this.maxContainerWidth = -125.36 + 3.79 * entry.contentRect.height; - this.containerWidth = Math.min( - entry.contentRect.width, - this.maxContainerWidth - ); + this.hostWidth = entry.contentRect.width; + this.hostHeight = entry.contentRect.height; } }); + /** Vertical space reserved for the readout block below the strip. */ + private static readonly READOUT_RESERVE_PX = 56; + + private get stripHeight(): number { + return ( + this.hostHeight - + (this.hasReadout ? ObcCompassFlat.READOUT_RESERVE_PX : 0) + ); + } + + // Made by chatGPT so that the text is inside the wrapper + private get maxContainerWidth(): number { + return -125.36 + 3.79 * this.stripHeight; + } + + private get containerWidth(): number { + return Math.min(this.hostWidth, this.maxContainerWidth); + } + override connectedCallback() { super.connectedCallback(); this.resizeObserver.observe(this); @@ -323,7 +367,7 @@ export class ObcCompassFlat extends LitElement { : -128; const viewBox = `-192 ${arrowViewBoxY} 384 128`; - return html` + const strip = html`
`; + + if (!this.hasReadout) { + return strip; + } + return html` +
+ ${strip} +
+ ${renderCenterReadouts([ + { + value: this.heading, + label: this.label, + unit: this.unit, + fractionDigits: this.fractionDigits, + size: ReadoutSize.large, + priority: this.priorityFor(CompassFlatPriorityElement.hdg), + centerValue: true, + centerMeta: true, + }, + ])} +
+
+ `; } - static override styles = unsafeCSS(componentStyle); + static override styles = [ + unsafeCSS(instrumentReadoutStyle), + centerReadoutStyles, + unsafeCSS(componentStyle), + ]; } declare global { diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/compass-sector/compass-sector.ts b/packages/openbridge-webcomponents/src/navigation-instruments/compass-sector/compass-sector.ts index 3f793d9de..10b0f4409 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/compass-sector/compass-sector.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/compass-sector/compass-sector.ts @@ -2,7 +2,11 @@ import {LitElement, PropertyValues, html, svg, unsafeCSS, nothing} from 'lit'; import {property} from 'lit/decorators.js'; import componentStyle from './compass-sector.css?inline'; import '../watch/watch.js'; -import {renderInstrumentReadout} from '../readout/instrument-readout.js'; +import { + centerReadoutStyles, + renderCenterReadouts, +} from '../readout/center-readout.js'; +import {ReadoutSize} from '../readout/readout.js'; import instrumentReadoutStyle from '../readout/instrument-readout.css?inline'; import {Tickmark, TickmarkType, TickmarkStyle} from '../watch/tickmark.js'; import {arrow, ArrowStyle} from '../compass/arrow.js'; @@ -552,15 +556,18 @@ export class ObcCompassSector extends LitElement { ${this.hasReadout ? html`
- ${renderInstrumentReadout({ - value: this.heading, - priority: this.priorityFor(CompassSectorPriorityElement.hdg), - label: this.label, - unit: this.unit, - fractionDigits: this.fractionDigits, - centerValue: true, - centerMeta: true, - })} + ${renderCenterReadouts([ + { + value: this.heading, + label: this.label, + unit: this.unit, + fractionDigits: this.fractionDigits, + size: ReadoutSize.large, + priority: this.priorityFor(CompassSectorPriorityElement.hdg), + centerValue: true, + centerMeta: true, + }, + ])}
` : nothing} @@ -569,6 +576,7 @@ export class ObcCompassSector extends LitElement { static override styles = [ unsafeCSS(instrumentReadoutStyle), + centerReadoutStyles, unsafeCSS(componentStyle), ]; } diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/compass/arrow.ts b/packages/openbridge-webcomponents/src/navigation-instruments/compass/arrow.ts index a6ff88bfe..bf81c882a 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/compass/arrow.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/compass/arrow.ts @@ -1,47 +1,34 @@ -import {SVGTemplateResult, svg} from 'lit'; +import {SVGTemplateResult} from 'lit'; import {Priority} from '../types.js'; +import { + CogArrowStyle, + HdgArrowStyle, + cogArrow, + hdgArrow, +} from '../course-arrows/course-arrows.js'; +/** @deprecated Use `HdgArrowStyle`/`CogArrowStyle` from `course-arrows/course-arrows.js` instead. */ export enum ArrowStyle { HDG = 'HDG', COG = 'COG', } +/** + * Ring-anchored arrow-head art for the compass family. + * @deprecated Use `hdgArrow`/`cogArrow` from `course-arrows/course-arrows.js` + * with the `arrowHead` style instead. + */ export function arrow( style: ArrowStyle, angle: number, priority: Priority = Priority.regular, radiusOffset = 0 ): SVGTemplateResult | SVGTemplateResult[] { - const colorName = - priority === Priority.enhanced - ? 'var(--instrument-enhanced-secondary-color)' - : 'var(--instrument-regular-secondary-color)'; - if (style === ArrowStyle.HDG) { - return svg` - - - - - `; + return hdgArrow(HdgArrowStyle.arrowHead, angle, priority, radiusOffset); } else if (style === ArrowStyle.COG) { - return svg` - - - - - - - - - - `; + return cogArrow(CogArrowStyle.arrowHead, angle, priority, radiusOffset); } else { return []; } - - // return [...shaft, circle, arrowTip]; } diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/compass/compass.stories.ts b/packages/openbridge-webcomponents/src/navigation-instruments/compass/compass.stories.ts index 3bf56ab23..5a2c5c2d5 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/compass/compass.stories.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/compass/compass.stories.ts @@ -1,11 +1,15 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; import { + CogArrowStyle, CompassDirection, CompassPriorityElement, + CompassReadoutSource, + HdgArrowStyle, ObcCompass, RotType, } from './compass.js'; import './compass.js'; +import {ReadoutSize} from '../readout/readout.js'; import {html} from 'lit'; import { playgroundColumn, @@ -100,6 +104,15 @@ const meta: Meta = { control: 'select', options: topVessels, }, + hdgArrowStyle: { + control: 'select', + options: Object.values(HdgArrowStyle), + }, + cogArrowStyle: { + control: 'select', + options: Object.values(CogArrowStyle), + }, + centerReadouts: {control: 'object'}, direction: { control: {type: 'select'}, options: Object.values(CompassDirection), @@ -183,6 +196,121 @@ export const WithRateOfTurnDegreesPerMinute: Story = { }, }; +export const WithCenterReadout: Story = { + tags: ['6.1'], + args: { + centerReadouts: [{source: CompassReadoutSource.hdg}], + }, +}; + +export const WithTwoPrimaryReadouts: Story = { + tags: ['6.1'], + args: { + centerReadouts: [ + {source: CompassReadoutSource.hdg}, + {source: CompassReadoutSource.cog, size: ReadoutSize.large}, + ], + }, +}; + +export const WithPrimarySecondaryReadouts: Story = { + tags: ['6.1'], + args: { + centerReadouts: [ + {source: CompassReadoutSource.hdg}, + {source: CompassReadoutSource.cog}, + ], + }, +}; + +export const WithThreeReadouts: Story = { + tags: ['6.1'], + args: { + rateOfTurnDegreesPerMinute: 12, + centerReadouts: [ + {source: CompassReadoutSource.hdg}, + {source: CompassReadoutSource.cog}, + {source: CompassReadoutSource.rot}, + ], + }, +}; + +/** + * The "map graphics" recipe: vessel, HDG vector with beam crossbar and + * astern line, COG arrow head, the rate-of-turn bar, and outside NSWE labels. + */ +export const MapGraphics: Story = { + tags: ['6.1'], + args: { + vesselImage: VesselImage.psvTop, + hdgArrowStyle: HdgArrowStyle.vector, + cogArrowStyle: CogArrowStyle.arrowHead, + rotType: RotType.bar, + rotPosition: RotPosition.innerCircle, + rateOfTurnDegreesPerMinute: 20, + rotMaxValue: 60, + showLabels: true, + tickmarksInside: false, + priorityElements: [CompassPriorityElement.hdg, CompassPriorityElement.rot], + }, +}; + +export const WithHdgArrowStyles: Story = { + tags: ['6.1'], + name: 'With HDG Arrow Styles', + parameters: {widthDecorator: false}, + render: () => html` +
+ ${Object.values(HdgArrowStyle).map( + (style) => html` +
+
+ +
+
+ ${style} +
+
+ ` + )} +
+ `, +}; + +export const WithCogArrowStyles: Story = { + tags: ['6.1'], + name: 'With COG Arrow Styles', + parameters: {widthDecorator: false}, + render: () => html` +
+ ${Object.values(CogArrowStyle).map( + (style) => html` +
+
+ +
+
+ ${style} +
+
+ ` + )} +
+ `, +}; + export const SmallContainer: Story = { name: 'Small Container (250px, Labels + Decor Reserve)', args: { diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/compass/compass.ts b/packages/openbridge-webcomponents/src/navigation-instruments/compass/compass.ts index 0926613a7..ecc23169a 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/compass/compass.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/compass/compass.ts @@ -1,8 +1,20 @@ -import {LitElement, PropertyValues, css, html} from 'lit'; +import {LitElement, PropertyValues, css, html, nothing} from 'lit'; import {property} from 'lit/decorators.js'; import '../watch/watch.js'; import {Tickmark, TickmarkType} from '../watch/tickmark.js'; -import {arrow, ArrowStyle} from './arrow.js'; +import { + CogArrowStyle, + HdgArrowStyle, + cogArrow, + hdgArrow, +} from '../course-arrows/course-arrows.js'; +import { + CompassCenterReadout, + CompassReadoutSource, + centerReadoutStyles, + renderCenterReadouts, + resolveCompassCenterReadouts, +} from '../readout/center-readout.js'; import {AdviceState, AngleAdvice, AngleAdviceRaw} from '../watch/advice.js'; import {ResizeController} from '@lit-labs/observers/resize-controller.js'; import { @@ -25,6 +37,9 @@ import {ROT_ZERO_DEADBAND_DEG} from '../rate-of-turn/rot-renderer.js'; import {customElement} from '../../decorator.js'; import {InstrumentState, Priority} from '../types.js'; export {RotType}; +export {HdgArrowStyle, CogArrowStyle}; +export {CompassReadoutSource}; +export type {CompassCenterReadout}; export enum CompassDirection { NorthUp = 'northUp', @@ -74,6 +89,13 @@ export enum CompassPriorityElement { * speed/direction indicators on the watch face. * - **Vessel image**: Configurable vessel silhouette centered on the * compass, rotating with heading. + * - **Arrow styles**: The HDG and COG arrows each select their look via + * `hdgArrowStyle` (`arrowHead` default, `needle`, `vector`, `beamLine`) + * and `cogArrowStyle` (`arrowHead` default, `needle`, `vector`, + * `velocityVector`). + * - **Center readouts**: `centerReadouts` replaces the vessel with up to + * three readouts (first on top, the rest below a horizontal divider); + * values bind to the instrument's own inputs per entry `source`. * - **Color priority**: Set `priority` to `Priority.enhanced` to use the * blue/enhanced color palette instead of the default gray/regular palette * (default: `Priority.regular`). @@ -167,8 +189,27 @@ export class ObcCompass extends LitElement { * @availableWhen currentSpeed!=null */ @property({type: Number}) currentFromDirection: number | null = null; - /** The image of the vessel. */ + /** + * The image of the vessel. Hidden while `centerReadouts` is non-empty. + * @availableWhen centerReadouts==[] + */ @property({type: String}) vesselImage: VesselImage = VesselImage.genericTop; + /** + * Center readouts replacing the vessel: the first entry renders on top, + * the rest side by side below a horizontal divider. Values bind per entry + * `source` (`hdg` → `heading`, `cog` → `courseOverGround`, `rot` → + * `rateOfTurnDegreesPerMinute`, a dash when unset) and colors follow + * `priorityElements`. While non-empty, the crosshair's center is cut out + * so the readouts sit on a clean face. + */ + @property({type: Array, attribute: false}) + centerReadouts: CompassCenterReadout[] = []; + /** HDG arrow style: `arrowHead` (default), `needle`, `vector`, or `beamLine`. */ + @property({type: String}) hdgArrowStyle: HdgArrowStyle = + HdgArrowStyle.arrowHead; + /** COG arrow style: `arrowHead` (default), `needle`, `vector`, or `velocityVector`. */ + @property({type: String}) cogArrowStyle: CogArrowStyle = + CogArrowStyle.arrowHead; /** * Measured rate of turn in degrees per minute (positive = starboard). * Drives both the bar extent and (after multiplication by @@ -327,6 +368,21 @@ export class ObcCompass extends LitElement { : undefined; } + private readoutPriorityFor(source: CompassReadoutSource): Priority { + switch (source) { + case CompassReadoutSource.hdg: + return this.priorityFor(CompassPriorityElement.hdg); + case CompassReadoutSource.cog: + return this.priorityFor(CompassPriorityElement.cog); + case CompassReadoutSource.rot: + return this.priorityFor(CompassPriorityElement.rot); + } + } + + private get hasCenterReadouts(): boolean { + return this.centerReadouts.length > 0; + } + private getRotation(): number | undefined { if (this.direction === CompassDirection.NorthUp) { return undefined; @@ -367,6 +423,7 @@ export class ObcCompass extends LitElement { .showLabels=${this.showLabels && !frame.labelsHidden} .tickmarksInside=${this.tickmarksInside} .crosshairEnabled=${true} + .crosshairCenterCutout=${this.hasCenterReadouts} .northArrow=${!frame.labelsHidden} .angleSetpoint=${this.headingSetpoint ?? undefined} .newAngleSetpoint=${this.newHeadingSetpoint} @@ -375,13 +432,15 @@ export class ObcCompass extends LitElement { .setpointOverride=${this.headingSetpointOverride} .priority=${this.priority} .animateSetpoint=${this.animateSetpoint} - .vessels=${[ - { - size: VesselImageSize.medium, - vesselImage: this.vesselImage, - transform: `rotate(${this.heading}deg)`, - }, - ]} + .vessels=${this.hasCenterReadouts + ? [] + : [ + { + size: VesselImageSize.medium, + vesselImage: this.vesselImage, + transform: `rotate(${this.heading}deg)`, + }, + ]} .windKnots=${this.currentWindSpeedKnots} .windFromDirectionDeg=${this.windFromDirection} .windColor=${this.colorFor(CompassPriorityElement.wind)} @@ -405,46 +464,68 @@ export class ObcCompass extends LitElement { > - ${arrow( - ArrowStyle.HDG, + ${hdgArrow( + this.hdgArrowStyle, this.heading + (this.getRotation() ?? 0), this.priorityFor(CompassPriorityElement.hdg) )} - ${arrow( - ArrowStyle.COG, + ${cogArrow( + this.cogArrowStyle, this.courseOverGround + (this.getRotation() ?? 0), this.priorityFor(CompassPriorityElement.cog) )} + ${this.hasCenterReadouts + ? html`
+ ${renderCenterReadouts( + resolveCompassCenterReadouts(this.centerReadouts, { + heading: this.heading, + courseOverGround: this.courseOverGround, + rateOfTurnDegreesPerMinute: this.rateOfTurnDegreesPerMinute, + priorityFor: (source) => this.readoutPriorityFor(source), + }) + )} +
` + : nothing} `; } - static override styles = css` - * { - box-sizing: border-box; - } + static override styles = [ + centerReadoutStyles, + css` + * { + box-sizing: border-box; + } - .container { - position: relative; - width: 100%; - height: 100%; - } + .container { + position: relative; + width: 100%; + height: 100%; + } - .container > * { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } + .container > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } - :host { - display: block; - width: 100%; - height: 100%; - } - `; + .center-readout-overlay { + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + + :host { + display: block; + width: 100%; + height: 100%; + } + `, + ]; } declare global { diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/course-arrows/course-arrows-art.ts b/packages/openbridge-webcomponents/src/navigation-instruments/course-arrows/course-arrows-art.ts new file mode 100644 index 000000000..4532a78be --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/course-arrows/course-arrows-art.ts @@ -0,0 +1,60 @@ +import {SVGTemplateResult, svg} from 'lit'; + +/** + * Figma-exported arrow glyph art for `course-arrows.ts` — do not hand-edit + * the path data. Source: OpenBridge 6.1 (file Tb5GjGfYoVIUrhXThL51A2), + * variant nodes 18306:91621, 18306:91630, 18306:91650, 18306:91656 (Enhanced-state + * exports; the state color is parameterized as `colorName`, the silhouette + * outline maps to `--border-silhouette-color`). Art is authored in the + * 512/center-256 space; each glyph is pre-translated to its Figma frame + * position and expects the caller's `rotate(angle) translate(-256, -256)` + * wrapper. + */ + +/** HDG vector: solid fore line + filled arrowhead + beam crossbar + dotted astern line. */ +export function hdgVectorArt(colorName: string): SVGTemplateResult { + return svg` + + + + + + +`; +} + +/** HDG beam-line: fore line + beam crossbar + dotted astern line, no arrowhead. */ +export function hdgBeamLineArt(colorName: string): SVGTemplateResult { + return svg` + + + + + + +`; +} + +/** COG vector: dashed shaft with a single open arrowhead. */ +export function cogVectorArt(colorName: string): SVGTemplateResult { + return svg` + + + + + + +`; +} + +/** COG velocity vector: dashed shaft with a double open arrowhead. */ +export function cogVelocityVectorArt(colorName: string): SVGTemplateResult { + return svg` + + + + + + +`; +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/course-arrows/course-arrows.ts b/packages/openbridge-webcomponents/src/navigation-instruments/course-arrows/course-arrows.ts new file mode 100644 index 000000000..7386007d4 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/course-arrows/course-arrows.ts @@ -0,0 +1,168 @@ +import {SVGTemplateResult, svg} from 'lit'; +import {Priority} from '../types.js'; +import { + cogVectorArt, + cogVelocityVectorArt, + hdgBeamLineArt, + hdgVectorArt, +} from './course-arrows-art.js'; + +/** + * Shared HDG/COG arrow art for the radial course instruments + * (`obc-compass`, `obc-heading`; `obc-compass-sector` consumes the arrow-head + * styles through the deprecated `compass/arrow.ts` shim). + * + * All art is authored in the 512/center-256 coordinate system and wrapped in + * `rotate(angle) translate(-256, -256)` groups, per the watch-instrument SVG + * conventions. Colors derive from the instrument priority palette. + * + * `radiusOffset` shifts ring-anchored art (the `arrowHead` styles) outward — + * `obc-compass-sector` passes its zoom radius offset here. Center-anchored + * styles (`needle`, `vector`, `beamLine`, `velocityVector`) ignore it. + */ + +export enum HdgArrowStyle { + arrowHead = 'arrowHead', + needle = 'needle', + vector = 'vector', + beamLine = 'beamLine', +} + +export enum CogArrowStyle { + arrowHead = 'arrowHead', + needle = 'needle', + vector = 'vector', + velocityVector = 'velocityVector', +} + +function arrowColor(priority: Priority): string { + return priority === Priority.enhanced + ? 'var(--instrument-enhanced-secondary-color)' + : 'var(--instrument-regular-secondary-color)'; +} + +function hdgArrowHead( + angle: number, + colorName: string, + radiusOffset: number +): SVGTemplateResult { + return svg` + + + + + `; +} + +function cogArrowHead( + angle: number, + colorName: string, + radiusOffset: number +): SVGTemplateResult { + return svg` + + + + + + + + + + `; +} + +function hdgNeedle(angle: number, colorName: string): SVGTemplateResult { + return svg` + + + + + + + + + `; +} + +function cogNeedle(angle: number, colorName: string): SVGTemplateResult { + return svg` + + + + + + + + + + + `; +} + +/** + * Renders the heading (HDG) arrow in the requested style, rotated to `angle` + * degrees (0° = 12 o'clock). + */ +export function hdgArrow( + style: HdgArrowStyle, + angle: number, + priority: Priority = Priority.regular, + radiusOffset = 0 +): SVGTemplateResult { + const colorName = arrowColor(priority); + switch (style) { + case HdgArrowStyle.arrowHead: + return hdgArrowHead(angle, colorName, radiusOffset); + case HdgArrowStyle.needle: + return hdgNeedle(angle, colorName); + case HdgArrowStyle.vector: + return svg` + + ${hdgVectorArt(colorName)} + + + `; + case HdgArrowStyle.beamLine: + return svg` + + ${hdgBeamLineArt(colorName)} + + + `; + } +} + +/** + * Renders the course-over-ground (COG) arrow in the requested style, rotated + * to `angle` degrees (0° = 12 o'clock). + */ +export function cogArrow( + style: CogArrowStyle, + angle: number, + priority: Priority = Priority.regular, + radiusOffset = 0 +): SVGTemplateResult { + const colorName = arrowColor(priority); + switch (style) { + case CogArrowStyle.arrowHead: + return cogArrowHead(angle, colorName, radiusOffset); + case CogArrowStyle.needle: + return cogNeedle(angle, colorName); + case CogArrowStyle.vector: + return svg` + + ${cogVectorArt(colorName)} + + `; + case CogArrowStyle.velocityVector: + return svg` + + ${cogVelocityVectorArt(colorName)} + + `; + } +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/draft-trim/draft-trim.stories.ts b/packages/openbridge-webcomponents/src/navigation-instruments/draft-trim/draft-trim.stories.ts new file mode 100644 index 000000000..4a27da595 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/draft-trim/draft-trim.stories.ts @@ -0,0 +1,67 @@ +import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import {ObcDraftTrim} from './draft-trim.js'; +import './draft-trim.js'; +import {widthDecorator} from '../../storybook-util.js'; +import {Priority} from '../types.js'; +import {AdviceType} from '../watch/advice.js'; +import {sideVessels} from '../watch/vessels/storybook-helper.js'; + +const meta: Meta = { + title: 'Instruments/Draft Trim', + tags: ['autodocs', '6.0', 'wip'], + component: 'obc-draft-trim', + args: { + width: 384, + draftFore: 2.5, + draftAft: 5, + instrumentRange: 10, + priority: Priority.regular, + }, + argTypes: { + width: {control: {type: 'range', min: 100, max: 1000, step: 1}}, + draftFore: {control: {type: 'range', min: -10, max: 10, step: 0.1}}, + draftAft: {control: {type: 'range', min: -10, max: 10, step: 0.1}}, + instrumentRange: {control: {type: 'range', min: 1, max: 50, step: 1}}, + vesselImage: {control: 'select', options: sideVessels}, + priority: {control: 'select', options: Object.values(Priority)}, + }, + decorators: [widthDecorator], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Enhanced: Story = { + args: { + priority: Priority.enhanced, + }, +}; + +export const EvenKeel: Story = { + args: { + draftFore: 4, + draftAft: 4, + }, +}; + +export const TrimByBow: Story = { + args: { + draftFore: 6, + draftAft: 2, + }, +}; + +export const Advice: Story = { + args: { + draftFore: 7, + draftAft: 8, + advice: [ + {min: 6, max: 10, type: AdviceType.caution, hinted: true}, + {min: -10, max: -6, type: AdviceType.caution, hinted: true}, + ], + }, +}; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/draft-trim/draft-trim.ts b/packages/openbridge-webcomponents/src/navigation-instruments/draft-trim/draft-trim.ts new file mode 100644 index 000000000..a9ffde520 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/draft-trim/draft-trim.ts @@ -0,0 +1,186 @@ +import {LitElement, html, css, nothing, svg, type SVGTemplateResult} from 'lit'; +import {property} from 'lit/decorators.js'; +import {customElement} from '../../decorator.js'; +import { + verticalScaleTickmarks, + watchfaceLinear, +} from '../../building-blocks/instrument-linear/instrument-linear.js'; +import { + LinearAdvice, + resolveLinearAdvice, +} from '../../building-blocks/instrument-linear/advice.js'; +import {VesselImage} from '../watch/watch.js'; +import {vesselImages} from '../watch/vessel.js'; +import {Priority} from '../types.js'; + +/** Height of the scales and of the centre panel, in SVG units. */ +const BOX_HEIGHT = 336; +/** Width of one scale column, matching `obc-heave`. */ +const GAUGE_WIDTH = 72; +/** Width of the tick lane inside a scale column. */ +const SCALE_WIDTH = 24; +/** Distance from the centre to each scale column's centre. */ +const GAUGE_OFFSET = BOX_HEIGHT / 2 - GAUGE_WIDTH / 2; +/** Half-width of the centre panel, i.e. the gap between the two columns. */ +const CENTRE_HALF_WIDTH = GAUGE_OFFSET - GAUGE_WIDTH / 2; +/** Vessel box side, and its vertical offset from the waterline (Figma). */ +const VESSEL_SIZE = 177.5; +const VESSEL_OFFSET_Y = 4.5; + +/** + * `` — Fore and aft draught indicator. + * + * Shows the draught at the bow and the stern on two vertical scales either + * side of a vessel side view, each filled from the waterline down to its + * measured value. The difference between the two is the vessel's trim. + * + * Features: + * - Two independent scales, `draftFore` on the right and `draftAft` on the + * left, the left one mirrored so its tick lane faces outward. + * - Each scale spans `±instrumentRange` around the waterline, so the lower + * half reads as draught and the upper half as freeboard. + * - Optional `advice` zones, shared by both scales and triggered + * independently by each draught. + * - `regular` / `enhanced` palette via `priority`, and a selectable + * `vesselImage`. + * + * Usage guidelines: + * - Values are given in the unit the scale is labelled in, positive below the + * waterline; a negative value therefore reads above it. + * - The centre panel is a static reference. The vessel sits at the waterline + * and does not pitch with the trim — the trim is read by comparing the two + * scales, not from the silhouette. + * - Use `obc-heave` instead when a single scale against a moving vessel is + * wanted; this component reuses the same scale for both of its columns. + * + * @element obc-draft-trim + * @experimental + */ +@customElement('obc-draft-trim') +export class ObcDraftTrim extends LitElement { + /** Draught at the bow, positive below the waterline. */ + @property({type: Number}) draftFore = 0; + /** Draught at the stern, positive below the waterline. */ + @property({type: Number}) draftAft = 0; + /** + * Half-extent of both scales. Each spans `±instrumentRange` around the + * waterline, so the upper half reads as freeboard. + */ + @property({type: Number}) instrumentRange = 10; + /** + * Advice zones, in the same positive-below-the-waterline units as the + * draughts. Applied to both scales; each is triggered by its own draught. + */ + @property({type: Array}) advice: LinearAdvice[] = []; + @property({type: String}) vesselImage: VesselImage = VesselImage.psvSide; + @property({type: String}) priority: Priority = Priority.regular; + + /** + * Scale value for a draught. The scales run positive-up like `obc-heave`, + * so a draught — measured downward from the waterline — is negated. + */ + private scaleValue(draft: number): number { + return -draft; + } + + /** The advice zones mapped into scale space, so they too read downward. */ + private get scaleAdvice(): LinearAdvice[] { + return this.advice.map((a) => ({...a, min: -a.max, max: -a.min})); + } + + private renderScale(draft: number, mirrored: boolean): SVGTemplateResult { + const value = this.scaleValue(draft); + const gauge = watchfaceLinear( + { + height: BOX_HEIGHT, + width: GAUGE_WIDTH, + scaleWidth: SCALE_WIDTH, + minValue: -this.instrumentRange, + maxValue: this.instrumentRange, + }, + [{min: Math.min(0, value), max: Math.max(0, value)}], + {value}, + {container: 'var(--instrument-frame-primary-color)'}, + { + hideContainer: false, + off: false, + priority: this.priority, + maskId: mirrored ? 'draftAftMask' : 'draftForeMask', + }, + verticalScaleTickmarks(this.instrumentRange), + resolveLinearAdvice( + this.scaleAdvice, + Math.min(0, value), + Math.max(0, value) + ) + ); + const x = mirrored ? -GAUGE_OFFSET : GAUGE_OFFSET; + return svg`${gauge}`; + } + + private renderCentre(): SVGTemplateResult { + const w = CENTRE_HALF_WIDTH; + const h = BOX_HEIGHT / 2; + const line = (y: number) => + svg``; + return svg` + + ${line(-h)} ${line(0)} ${line(h)} + `; + } + + private renderVessel(): SVGTemplateResult { + const scale = VESSEL_SIZE / 160; + return svg` + + ${this.vesselImage ? vesselImages[this.vesselImage] : nothing} + + `; + } + + override render() { + return html` +
+ + ${this.renderCentre()} ${this.renderVessel()} + ${this.renderScale(this.draftAft, true)} + ${this.renderScale(this.draftFore, false)} + +
+ `; + } + + static override styles = css` + * { + box-sizing: border-box; + } + + .container { + position: relative; + width: 100%; + height: 100%; + } + + .container > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + 'obc-draft-trim': ObcDraftTrim; + } +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/heading/arrow.ts b/packages/openbridge-webcomponents/src/navigation-instruments/heading/arrow.ts index 2517c505d..d0f24b594 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/heading/arrow.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/heading/arrow.ts @@ -1,48 +1,33 @@ -import {SVGTemplateResult, svg} from 'lit'; +import {SVGTemplateResult} from 'lit'; import {Priority} from '../types.js'; +import { + CogArrowStyle, + HdgArrowStyle, + cogArrow, + hdgArrow, +} from '../course-arrows/course-arrows.js'; +/** @deprecated Use `HdgArrowStyle`/`CogArrowStyle` from `course-arrows/course-arrows.js` instead. */ export enum ArrowStyle { HDG = 'HDG', COG = 'COG', } +/** + * Center-anchored needle art for the heading instrument. + * @deprecated Use `hdgArrow`/`cogArrow` from `course-arrows/course-arrows.js` + * with the `needle` style instead. + */ export function arrow( style: ArrowStyle, angle: number, priority: Priority = Priority.regular ): SVGTemplateResult | SVGTemplateResult[] { - const colorName = - priority === Priority.enhanced - ? 'var(--instrument-enhanced-secondary-color)' - : 'var(--instrument-regular-secondary-color)'; - if (style === ArrowStyle.HDG) { - return svg` - - - - - - - - - `; + return hdgArrow(HdgArrowStyle.needle, angle, priority); } else if (style === ArrowStyle.COG) { - return svg` - - - - - - - - - - - `; + return cogArrow(CogArrowStyle.needle, angle, priority); } else { return []; } - - // return [...shaft, circle, arrowTip]; } diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/heading/heading.stories.ts b/packages/openbridge-webcomponents/src/navigation-instruments/heading/heading.stories.ts index 09b0b9f4e..d04b6fe41 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/heading/heading.stories.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/heading/heading.stories.ts @@ -1,10 +1,16 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; import { + CogArrowStyle, CompassDirection, + CompassReadoutSource, + HdgArrowStyle, HeadingPriorityElement, ObcHeading, } from './heading.js'; import './heading.js'; +import {VesselImage} from '../watch/watch.js'; +import {topVessels} from '../watch/vessels/storybook-helper.js'; +import {ReadoutSize} from '../readout/readout.js'; import {html} from 'lit'; import { playgroundColumn, @@ -17,7 +23,7 @@ import {Priority} from '../types.js'; const meta: Meta = { title: 'Instruments/Heading', - tags: ['6.0'], + tags: ['autodocs', '6.0'], component: 'obc-heading', args: { width: 512, @@ -54,6 +60,16 @@ const meta: Meta = { touching: {control: 'boolean'}, showLabels: {control: 'boolean'}, tickmarksInside: {control: 'boolean'}, + vesselImage: {control: 'select', options: [undefined, ...topVessels]}, + hdgArrowStyle: { + control: 'select', + options: Object.values(HdgArrowStyle), + }, + cogArrowStyle: { + control: 'select', + options: Object.values(CogArrowStyle), + }, + centerReadouts: {control: 'object'}, priority: {control: 'select', options: Object.values(Priority)}, priorityElements: { control: 'multi-select', @@ -79,6 +95,37 @@ export const Enhanced: Story = { }, }; +export const WithVessel: Story = { + tags: ['6.1'], + args: { + vesselImage: VesselImage.psvTop, + hdgArrowStyle: HdgArrowStyle.arrowHead, + cogArrowStyle: CogArrowStyle.arrowHead, + }, +}; + +export const WithCenterReadout: Story = { + tags: ['6.1'], + args: { + centerReadouts: [ + {source: CompassReadoutSource.hdg}, + {source: CompassReadoutSource.cog, size: ReadoutSize.large}, + ], + hdgArrowStyle: HdgArrowStyle.arrowHead, + cogArrowStyle: CogArrowStyle.arrowHead, + priority: Priority.enhanced, + }, +}; + +export const WithVectorArrows: Story = { + tags: ['6.1'], + args: { + vesselImage: VesselImage.psvTop, + hdgArrowStyle: HdgArrowStyle.vector, + cogArrowStyle: CogArrowStyle.vector, + }, +}; + /** * Below the label-reserve cap (~200px for the NSWE + north-arrow decor) the * frame reports `labelsHidden` and the instrument drops the labels and the diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/heading/heading.ts b/packages/openbridge-webcomponents/src/navigation-instruments/heading/heading.ts index d55f924f3..9c7081052 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/heading/heading.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/heading/heading.ts @@ -1,8 +1,20 @@ -import {LitElement, PropertyValues, css, html} from 'lit'; +import {LitElement, PropertyValues, css, html, nothing} from 'lit'; import {property} from 'lit/decorators.js'; import '../watch/watch.js'; import {Tickmark, TickmarkType} from '../watch/tickmark.js'; -import {arrow, ArrowStyle} from './arrow.js'; +import { + CogArrowStyle, + HdgArrowStyle, + cogArrow, + hdgArrow, +} from '../course-arrows/course-arrows.js'; +import { + CompassCenterReadout, + CompassReadoutSource, + centerReadoutStyles, + renderCenterReadouts, + resolveCompassCenterReadouts, +} from '../readout/center-readout.js'; import {AdviceState, AngleAdvice, AngleAdviceRaw} from '../watch/advice.js'; import {ResizeController} from '@lit-labs/observers/resize-controller.js'; import { @@ -13,10 +25,13 @@ import { NSWE_LABEL_WIDTH_PX, type RadialFrame, } from '../../svghelpers/radial-frame.js'; -import {WatchCircleType} from '../watch/watch.js'; +import {VesselImage, VesselImageSize, WatchCircleType} from '../watch/watch.js'; import {SetpointBundle} from '../../svghelpers/setpoint-bundle.js'; import {Priority} from '../types.js'; import {customElement} from '../../decorator.js'; +export {HdgArrowStyle, CogArrowStyle}; +export {CompassReadoutSource}; +export type {CompassCenterReadout}; export enum CompassDirection { NorthUp = 'northUp', @@ -30,6 +45,56 @@ export enum HeadingPriorityElement { } /** + * `` – Heading instrument with HDG/COG arrows over a single-ring + * watch face, with optional vessel silhouette and center readouts. + * + * Renders a circular instrument that displays heading (HDG) and course over + * ground (COG) as rotating arrows. It supports a heading setpoint with auto + * at-setpoint detection, advice zones, north-up/heading-up/course-up + * orientation, an optional vessel image, and center readouts replacing the + * vessel. + * + * ## Features + * + * - **Direction modes**: `northUp` (default), `headingUp`, or `courseUp` + * via the `direction` property. + * - **HDG / COG arrows**: Two arrows overlay the watch face, rotating + * independently; their look is selected per arrow via `hdgArrowStyle` + * (`needle` default, `arrowHead`, `vector`, `beamLine`) and + * `cogArrowStyle` (`needle` default, `arrowHead`, `vector`, + * `velocityVector`). + * - **Vessel image**: Optional vessel silhouette via `vesselImage` + * (hidden by default), centered and rotating with heading. + * - **Center readouts**: `centerReadouts` replaces the vessel with up to + * three readouts (first on top, the rest below a horizontal divider); + * values bind to the instrument's own inputs per entry `source`. + * - **Heading setpoint**: Optional setpoint marker with auto at-setpoint + * detection via `headingSetpoint`, `atHeadingSetpoint`, and deadband + * tuning properties. + * - **Advice zones**: Pass `headingAdvices` to render caution/alert arcs. + * - **Color priority**: Set `priority` to `Priority.enhanced` and select + * the accented elements via `priorityElements`. + * + * ## Usage Guidelines + * + * - Set `heading` and `courseOverGround` to the current sensor values in + * degrees. + * - Use `obc-compass` instead when rate-of-turn, wind or current overlays + * are needed — this instrument is the reduced heading-focused variant. + * + * ## Example + * + * ```html + * + * ``` + * + * @ignition-base-height: 512px + * @ignition-base-width: 512px * @stable */ @customElement('obc-heading') @@ -62,6 +127,27 @@ export class ObcHeading extends LitElement { /** @availableWhen priority==enhanced */ @property({type: Array, attribute: false}) priorityElements: HeadingPriorityElement[] = [HeadingPriorityElement.hdg]; + /** + * The vessel silhouette shown in the center, rotating with heading. When + * unset (default) no vessel is shown; hidden while `centerReadouts` is + * non-empty. + * @availableWhen centerReadouts==[] + */ + @property({type: String}) vesselImage: VesselImage | undefined; + /** + * Center readouts replacing the vessel: the first entry renders on top, + * the rest side by side below a horizontal divider. Values bind per entry + * `source` (`hdg` → `heading`, `cog` → `courseOverGround`; `rot` has no + * input on this instrument and renders a dash) and colors follow + * `priorityElements`. While non-empty, the crosshair's center is cut out + * so the readouts sit on a clean face. + */ + @property({type: Array, attribute: false}) + centerReadouts: CompassCenterReadout[] = []; + /** HDG arrow style: `needle` (default), `arrowHead`, `vector`, or `beamLine`. */ + @property({type: String}) hdgArrowStyle: HdgArrowStyle = HdgArrowStyle.needle; + /** COG arrow style: `needle` (default), `arrowHead`, `vector`, or `velocityVector`. */ + @property({type: String}) cogArrowStyle: CogArrowStyle = CogArrowStyle.needle; /** Show compass NSEW labels. */ @property({type: Boolean}) showLabels: boolean = false; /** When true, labels and north arrow are placed inside the outer ring. */ @@ -150,6 +236,30 @@ export class ObcHeading extends LitElement { return selected.includes(element) ? this.priority : Priority.regular; } + private readoutPriorityFor(source: CompassReadoutSource): Priority { + if (source === CompassReadoutSource.hdg) { + return this.priorityFor(HeadingPriorityElement.hdg); + } + if (source === CompassReadoutSource.cog) { + return this.priorityFor(HeadingPriorityElement.cog); + } + return Priority.regular; + } + + private get hasCenterReadouts(): boolean { + return this.centerReadouts.length > 0; + } + + /** + * The face gains the double-band ring while center content (vessel or + * readouts) is shown, per the design's vessel/center-label faces. + */ + private get watchCircleType(): WatchCircleType { + return this.vesselImage !== undefined || this.hasCenterReadouts + ? WatchCircleType.double + : WatchCircleType.single; + } + private getRotation(): number | undefined { if (this.direction === CompassDirection.NorthUp) { return undefined; @@ -185,10 +295,20 @@ export class ObcHeading extends LitElement { .arcFrame=${frame} .advices=${this.angleAdviceRaw} .tickmarks=${tickmarks} - .watchCircleType=${WatchCircleType.single} + .watchCircleType=${this.watchCircleType} + .vessels=${this.vesselImage !== undefined && !this.hasCenterReadouts + ? [ + { + size: VesselImageSize.medium, + vesselImage: this.vesselImage, + transform: `rotate(${this.heading}deg)`, + }, + ] + : []} .showLabels=${this.showLabels && !frame.labelsHidden} .tickmarksInside=${this.tickmarksInside} .crosshairEnabled=${true} + .crosshairCenterCutout=${this.hasCenterReadouts} .northArrow=${!frame.labelsHidden} .angleSetpoint=${this.headingSetpoint ?? undefined} .newAngleSetpoint=${this.newHeadingSetpoint} @@ -201,46 +321,67 @@ export class ObcHeading extends LitElement { > - ${arrow( - ArrowStyle.HDG, + ${hdgArrow( + this.hdgArrowStyle, this.heading + (this.getRotation() ?? 0), this.priorityFor(HeadingPriorityElement.hdg) )} - ${arrow( - ArrowStyle.COG, + ${cogArrow( + this.cogArrowStyle, this.courseOverGround + (this.getRotation() ?? 0), this.priorityFor(HeadingPriorityElement.cog) )} + ${this.hasCenterReadouts + ? html`
+ ${renderCenterReadouts( + resolveCompassCenterReadouts(this.centerReadouts, { + heading: this.heading, + courseOverGround: this.courseOverGround, + priorityFor: (source) => this.readoutPriorityFor(source), + }) + )} +
` + : nothing} `; } - static override styles = css` - * { - box-sizing: border-box; - } + static override styles = [ + centerReadoutStyles, + css` + * { + box-sizing: border-box; + } - .container { - position: relative; - width: 100%; - height: 100%; - } + .container { + position: relative; + width: 100%; + height: 100%; + } - .container > * { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } + .container > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } - :host { - display: block; - width: 100%; - height: 100%; - } - `; + .center-readout-overlay { + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + + :host { + display: block; + width: 100%; + height: 100%; + } + `, + ]; } declare global { diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/heave/heave.stories.ts b/packages/openbridge-webcomponents/src/navigation-instruments/heave/heave.stories.ts index 9fcdd5c99..a5505ee71 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/heave/heave.stories.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/heave/heave.stories.ts @@ -1,5 +1,5 @@ import type {Meta, StoryObj} from '@storybook/web-components-vite'; -import {ObcHeave} from './heave.js'; +import {ObcHeave, ObcHeaveType} from './heave.js'; import './heave.js'; import {widthDecorator} from '../../storybook-util.js'; import {VesselImage} from '../watch/watch.js'; @@ -30,6 +30,7 @@ const meta: Meta = { options: foreVessels, }, priority: {control: 'select', options: Object.values(Priority)}, + type: {control: 'select', options: Object.values(ObcHeaveType)}, }, decorators: [widthDecorator], } satisfies Meta; @@ -60,3 +61,27 @@ export const Advice: Story = { ], }, }; + +const barArgs = { + type: ObcHeaveType.bar, + width: 128, + height: 384, +}; + +export const Bar: Story = { + args: {...barArgs}, +}; + +export const BarEnhanced: Story = { + args: {...barArgs, priority: Priority.enhanced}, +}; + +export const BarAdvice: Story = { + args: { + ...barArgs, + advice: [ + {min: -10, max: -2, type: AdviceType.caution, hinted: true}, + {min: 2, max: 10, type: AdviceType.caution, hinted: true}, + ], + }, +}; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/heave/heave.ts b/packages/openbridge-webcomponents/src/navigation-instruments/heave/heave.ts index 60f9148d7..c4833eefa 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/heave/heave.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/heave/heave.ts @@ -1,31 +1,65 @@ -import {LitElement, html, css, nothing} from 'lit'; +import {LitElement, html, css, nothing, svg, type SVGTemplateResult} from 'lit'; import {customElement} from '../../decorator.js'; -import {watchfaceLinear} from '../../building-blocks/instrument-linear/instrument-linear.js'; +import { + verticalScaleTickmarks, + watchfaceLinear, +} from '../../building-blocks/instrument-linear/instrument-linear.js'; import {property} from 'lit/decorators.js'; import {VesselImage} from '../watch/watch.js'; import {vesselImages} from '../watch/vessel.js'; import { LinearAdvice, - LinearAdviceRaw, + resolveLinearAdvice, } from '../../building-blocks/instrument-linear/advice.js'; -import {AdviceState} from '../watch/advice.js'; import {Priority} from '../types.js'; +export enum ObcHeaveType { + /** Vessel silhouette in a framed box, with the scale column at its right. */ + vessel = 'vessel', + /** Scale column on its own — no frame, reference line or vessel. */ + bar = 'bar', +} + +/** `vessel` viewBox: the 336×336 frame plus its surrounding padding. */ +const VESSEL_VIEW_BOX = '-200 -200 400 400'; +/** `bar` viewBox: the 72×336 scale column centred in a 128×384 frame. */ +const BAR_VIEW_BOX = '-64 -192 128 384'; + /** + * `` — Vertical heave indicator. + * + * Shows `heave` on a linear scale with a trend band, optional advice zones and + * a `regular`/`enhanced` palette. + * + * Variants (`type`): + * - `vessel` (default) — the scale beside a framed box holding a vessel + * silhouette that rises and falls with the measured heave. + * - `bar` — the scale column alone, for use beside another instrument or in a + * narrow strip. The host is 128×384 rather than square. + * + * @element obc-heave * @stable */ @customElement('obc-heave') export class ObcHeave extends LitElement { @property({type: Number}) heave = 0; + /** @availableWhen type==vessel */ @property({type: Number}) gainScale = 10; @property({type: Number}) minTrendHeave = 0; @property({type: Number}) maxTrendHeave = 0; + /** @availableWhen type==vessel */ @property({type: Number}) draftOffset = 0; @property({type: Array}) advice: LinearAdvice[] = []; @property({type: Number}) instrumentRange = 10; + /** @availableWhen type==vessel */ @property({type: String}) vesselImage: VesselImage = VesselImage.psvFore; @property({type: String}) priority: Priority = Priority.regular; + /** + * `vessel` (default) frames the scale next to a vessel silhouette; `bar` + * renders the scale column on its own. + */ + @property({type: String}) type: ObcHeaveType = ObcHeaveType.vessel; private _toTranslatedValue(value: number) { return (value * (this._boxWidth / 2)) / this.instrumentRange; @@ -34,34 +68,12 @@ export class ObcHeave extends LitElement { _gaugeWidth = 72; _scaleWidth = 24; - private _getAdvice(): LinearAdviceRaw[] { - return this.advice.map((advice) => { - const isActive = - this.maxTrendHeave >= advice.min && this.minTrendHeave <= advice.max; - const state = isActive - ? AdviceState.triggered - : advice.hinted - ? AdviceState.hinted - : AdviceState.regular; - return { - ...advice, - min: advice.min, - max: advice.max, - state, - } satisfies LinearAdviceRaw; - }); - } - - override render() { - const gaugeOffset = this._boxWidth / 2 - this._gaugeWidth / 2; + private renderFrame(): SVGTemplateResult { const r = 8; - - return html` -
- + return svg` + `; + } + + private renderVessel(): SVGTemplateResult { + return svg` + + + + + + + + ${this.vesselImage ? vesselImages[this.vesselImage] : nothing} + + + `; + } + + override render() { + const isBar = this.type === ObcHeaveType.bar; + const gaugeOffset = isBar ? 0 : this._boxWidth / 2 - this._gaugeWidth / 2; + + return html` +
+ + ${isBar ? nothing : this.renderFrame()} ${watchfaceLinear( { @@ -99,34 +147,15 @@ export class ObcHeave extends LitElement { }, {container: 'var(--instrument-frame-primary-color)'}, {hideContainer: false, off: false, priority: this.priority}, - { - mainTickmarks: [0], - primaryTickmarkInterval: this.instrumentRange <= 5 ? 1 : 5, - secondaryTickmarkInterval: this.instrumentRange <= 5 ? 0.5 : 1, - }, - this._getAdvice() + verticalScaleTickmarks(this.instrumentRange), + resolveLinearAdvice( + this.advice, + this.minTrendHeave, + this.maxTrendHeave + ) )} - - - - - - - - ${this.vesselImage ? vesselImages[this.vesselImage] : nothing} - - + ${isBar ? nothing : this.renderVessel()}
`; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts b/packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts new file mode 100644 index 000000000..489872d9f --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll-heave/pitch-roll-heave.stories.ts @@ -0,0 +1,147 @@ +import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import { + ObcPitchRollHeave, + ObcPitchRollHeaveType, + PitchRollHeavePriorityElement, +} from './pitch-roll-heave.js'; +import './pitch-roll-heave.js'; +import {widthDecorator} from '../../storybook-util.js'; +import {Priority} from '../types.js'; +import {AdviceType} from '../watch/advice.js'; +import { + foreVessels, + fadedSideVessels, +} from '../watch/vessels/storybook-helper.js'; + +const meta: Meta = { + title: 'Instruments/Pitch Roll Heave', + tags: ['autodocs', '6.0', 'wip'], + component: 'obc-pitch-roll-heave', + args: { + width: 384, + type: ObcPitchRollHeaveType.singleScale, + pitch: 3, + roll: -10, + heave: 0.5, + minAvgPitch: -6, + maxAvgPitch: 6, + minAvgRoll: -10, + maxAvgRoll: 10, + minTrendHeave: -1, + maxTrendHeave: 1, + heaveRange: 10, + pitchArcAngle: 30, + rollArcAngle: 45, + scaleForeImage: 1, + priority: Priority.regular, + priorityElements: [ + PitchRollHeavePriorityElement.pitch, + PitchRollHeavePriorityElement.roll, + PitchRollHeavePriorityElement.heave, + ], + }, + argTypes: { + width: {control: {type: 'range', min: 100, max: 1000, step: 1}}, + type: {control: 'select', options: Object.values(ObcPitchRollHeaveType)}, + pitch: {control: {type: 'range', min: -30, max: 30, step: 0.1}}, + roll: {control: {type: 'range', min: -45, max: 45, step: 0.1}}, + heave: {control: {type: 'range', min: -10, max: 10, step: 0.1}}, + heaveRange: {control: {type: 'range', min: 1, max: 50, step: 1}}, + pitchArcAngle: {control: {type: 'range', min: 5, max: 45, step: 1}}, + rollArcAngle: {control: {type: 'range', min: 5, max: 60, step: 1}}, + scaleForeImage: {control: {type: 'range', min: 0, max: 2, step: 0.01}}, + priority: {control: 'select', options: Object.values(Priority)}, + priorityElements: { + control: 'multi-select', + options: Object.values(PitchRollHeavePriorityElement), + }, + vesselImageSide: {control: 'select', options: fadedSideVessels}, + vesselImageFore: {control: 'select', options: foreVessels}, + hasReadout: {control: 'boolean'}, + zoomToFitArc: {control: 'boolean'}, + }, + decorators: [widthDecorator], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: {}, +}; + +export const Enhanced: Story = { + args: { + priority: Priority.enhanced, + }, +}; + +export const DualScale: Story = { + args: { + type: ObcPitchRollHeaveType.dualScale, + }, +}; + +export const DualScaleEnhanced: Story = { + args: { + type: ObcPitchRollHeaveType.dualScale, + priority: Priority.enhanced, + }, +}; + +export const WithReadout: Story = { + args: { + hasReadout: true, + }, +}; + +export const WithReadoutEnhanced: Story = { + args: { + hasReadout: true, + priority: Priority.enhanced, + }, +}; + +export const Advice: Story = { + args: { + maxPitchAdvice: 5, + maxRollAdvice: 15, + triggerPitchAdvice: true, + triggerRollAdvice: false, + heaveAdvice: [ + {min: -10, max: -2, type: AdviceType.caution, hinted: true}, + {min: 2, max: 10, type: AdviceType.caution, hinted: true}, + ], + }, +}; + +export const ZoomedIn: Story = { + args: { + zoomToFitArc: true, + }, +}; + +export const ZoomedInDualScale: Story = { + args: { + zoomToFitArc: true, + type: ObcPitchRollHeaveType.dualScale, + }, +}; + +export const ZoomedInNarrow: Story = { + args: { + zoomToFitArc: true, + pitchArcAngle: 10, + rollArcAngle: 14, + pitch: 4, + roll: 6, + heave: 1.2, + minAvgPitch: -3, + maxAvgPitch: 5, + minAvgRoll: -4, + maxAvgRoll: 7, + minTrendHeave: -2, + maxTrendHeave: 2, + heaveRange: 5, + }, +}; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll-heave/pitch-roll-heave.ts b/packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll-heave/pitch-roll-heave.ts new file mode 100644 index 000000000..4f29d0875 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll-heave/pitch-roll-heave.ts @@ -0,0 +1,957 @@ +import {LitElement, css, html, nothing, svg, type SVGTemplateResult} from 'lit'; +import {property} from 'lit/decorators.js'; +import '../watch/watch.js'; +import { + VesselImage, + VesselImageSize, + WatchCircleType, + type WatchArea, + OUTER_RING_RADIUS, + innerRingRadiusFor, + vesselImages, +} from '../watch/watch.js'; +import {arcTickmarks, TickmarkType, type Tickmark} from '../watch/tickmark.js'; +import {AdviceState, AdviceType, type AngleAdviceRaw} from '../watch/advice.js'; +import {customElement} from '../../decorator.js'; +import {Priority} from '../types.js'; +import { + CenterReadoutArrangement, + centerReadoutStyles, + renderCenterReadouts, +} from '../readout/center-readout.js'; +import {ReadoutSize} from '../readout/readout.js'; +import { + computeZoomToFitArcFrame, + normalizeArcAngle, + shiftArcFrameToOuterEdge, +} from '../../svghelpers/arc-frame.js'; +import { + linearTickInterval, + watchfaceLinear, +} from '../../building-blocks/instrument-linear/instrument-linear.js'; +import type { + LinearAdvice, + LinearAdviceRaw, +} from '../../building-blocks/instrument-linear/advice.js'; + +export enum ObcPitchRollHeaveType { + /** Pitch on the right, roll at the bottom, heave in the left band slot. */ + singleScale = 'single-scale', + /** Pitch and roll mirrored onto both sides, heave centred. */ + dualScale = 'dual-scale', +} + +export enum PitchRollHeavePriorityElement { + pitch = 'pitch', + roll = 'roll', + heave = 'heave', +} + +/** Half-side of the centre overlay viewBox in SVG units. */ +const CENTRE_HALF = 200; + +/** + * Minimum diagonal clearance, in central-layer pixels, between the zoomed + * heave column and the adjacent roll band. Matches the constant + * `obc-pitch-roll` uses between its own adjacent arcs. + */ +const CORNER_GAP_PX = 32; + +/** Numerical safety floor when an axis arc has to collapse for clearance. */ +const MIN_ARC_HALF_DEG = 2; + +/** Width of the tick lane inside the heave column, in SVG units. */ +const HEAVE_SCALE_WIDTH = 24; + +/** Band thickness at the natural, un-zoomed radii. */ +const NOMINAL_BAND = + OUTER_RING_RADIUS - innerRingRadiusFor(WatchCircleType.double); + +/** + * Clearance kept between the centred (`dual-scale`) heave column's corners and + * the surrounding band, in central-layer pixels. Sized so the un-zoomed + * default is untouched — there the corner sits 13.2 units inside the free + * radius — while the tighter zoomed layout is pulled back off the bands. + */ +const HEAVE_CENTRE_GAP_PX = 10; + +/** + * The band slot the heave column fills, in central-layer coordinates. + * + * `outerR`/`innerR` bound the slot radially; `capRadius` is the radius of the + * pitch arc's outer edge about its own origin, which is what the column's + * height is measured against so the two line up. Un-zoomed the two radii + * coincide; under zoom the arc is drawn on a shifted sub-watch and they differ. + */ +interface HeaveBand { + outerR: number; + innerR: number; + capRadius: number; + pitchDeg: number; +} + +/** + * Natural size of the heave column: as thick as the band slot, and tall enough + * that its top and bottom edges sit level with the pitch arc's end caps. + */ +function heaveColumnSize(band: HeaveBand): {width: number; height: number} { + return { + width: band.outerR - band.innerR, + height: 2 * band.capRadius * Math.sin((band.pitchDeg * Math.PI) / 180), + }; +} + +/** + * Distance from a point to an axis-aligned rectangle; 0 when the point is + * inside. Used to keep the zoomed roll band clear of the heave column, which + * — unlike the arc-to-arc case — is a rectangle, so a corner may clear it on + * one axis alone. + */ +function distanceToRect( + px: number, + py: number, + x0: number, + y0: number, + x1: number, + y1: number +): number { + const dx = Math.max(0, x0 - px, px - x1); + const dy = Math.max(0, y0 - py, py - y1); + return Math.hypot(dx, dy); +} + +/** + * `` — Combined pitch, roll and heave indicator. + * + * Places a pitch arc, a roll arc and a linear heave column around one watch + * face. The heave column occupies the same radial slot as the arcs, and its + * height is derived from the pitch arc so its top and bottom edges sit level + * with the pitch arc's end caps. + * + * Variants (`type`): + * - `single-scale` (default) — pitch on the right, roll at the bottom, heave in + * the left slot, vessel silhouettes in the centre. + * - `dual-scale` — pitch and roll mirrored onto the opposite sides, with the + * heave column moved to the centre. + * + * Set `hasReadout` to replace the centre content with stacked pitch, roll and + * heave readouts, and `zoomToFitArc` to enlarge narrow arcs so they fill the + * available space. + * + * @element obc-pitch-roll-heave + * @experimental + */ +@customElement('obc-pitch-roll-heave') +export class ObcPitchRollHeave extends LitElement { + @property({type: String}) type: ObcPitchRollHeaveType = + ObcPitchRollHeaveType.singleScale; + + @property({type: Number}) pitch = 0; + @property({type: Number}) roll = 0; + @property({type: Number}) heave = 0; + @property({type: Number}) minAvgPitch = 0; + @property({type: Number}) maxAvgPitch = 0; + @property({type: Number}) minAvgRoll = 0; + @property({type: Number}) maxAvgRoll = 0; + @property({type: Number}) minTrendHeave = 0; + @property({type: Number}) maxTrendHeave = 0; + /** Half-extent of the heave scale in metres; the column spans `±heaveRange`. */ + @property({type: Number}) heaveRange = 10; + + /** + * Half-extent of the pitch arc in degrees. Also drives the heave column + * height, which is `2 · outerRadius · sin(pitchArcAngle)` so the column's + * edges stay level with the pitch arc's end caps. + */ + @property({type: Number}) pitchArcAngle = 30; + /** Half-extent of the roll arc in degrees. */ + @property({type: Number}) rollArcAngle = 45; + @property({type: Boolean}) zoomToFitArc = false; + + /** + * Replaces the centre content with stacked pitch, roll and heave readouts. + * Ignored when `type` is `dual-scale`, where the heave column occupies the + * centre. + * @availableWhen type==singleScale + */ + @property({type: Boolean}) hasReadout = false; + /** @availableWhen hasReadout==true */ + @property({type: String}) pitchLabel = 'Pitch'; + /** @availableWhen hasReadout==true */ + @property({type: String}) rollLabel = 'Roll'; + /** @availableWhen hasReadout==true */ + @property({type: String}) heaveLabel = 'Heave'; + /** + * Unit shown in the pitch and roll readouts. + * @availableWhen hasReadout==true + */ + @property({type: String}) unit = 'DEG'; + /** + * Unit shown in the heave readout. + * @availableWhen hasReadout==true + */ + @property({type: String}) heaveUnit = 'm'; + /** @availableWhen hasReadout==true */ + @property({type: Number}) fractionDigits = 0; + + /** @availableWhen type==singleScale && hasReadout==false */ + @property({type: String}) vesselImageFore: VesselImage = VesselImage.psvFore; + /** @availableWhen type==singleScale && hasReadout==false */ + @property({type: String}) vesselImageSide: VesselImage = VesselImage.psvSide; + /** @availableWhen type==singleScale && hasReadout==false */ + @property({type: Number}) scaleForeImage = 1; + + @property({type: Number}) maxPitchAdvice: number | undefined = undefined; + @property({type: Number}) maxRollAdvice: number | undefined = undefined; + /** @availableWhen maxPitchAdvice!=undefined */ + @property({type: Boolean}) triggerPitchAdvice = false; + /** @availableWhen maxRollAdvice!=undefined */ + @property({type: Boolean}) triggerRollAdvice = false; + @property({type: Array}) heaveAdvice: LinearAdvice[] = []; + + @property({type: String}) priority: Priority = Priority.regular; + @property({type: Array, attribute: false}) + priorityElements: PitchRollHeavePriorityElement[] = [ + PitchRollHeavePriorityElement.pitch, + PitchRollHeavePriorityElement.roll, + PitchRollHeavePriorityElement.heave, + ]; + + private priorityFor(element: PitchRollHeavePriorityElement): Priority { + const selected = Array.isArray(this.priorityElements) + ? this.priorityElements + : []; + return selected.includes(element) ? this.priority : Priority.regular; + } + + private needleColor(element: PitchRollHeavePriorityElement): string { + return this.priorityFor(element) === Priority.enhanced + ? 'var(--instrument-enhanced-secondary-color)' + : 'var(--instrument-regular-secondary-color)'; + } + + private barColor(element: PitchRollHeavePriorityElement): string { + return this.priorityFor(element) === Priority.enhanced + ? 'var(--instrument-enhanced-tertiary-color)' + : 'var(--instrument-regular-tertiary-color)'; + } + + private get normalizedScaleForeImage(): number { + if (!Number.isFinite(this.scaleForeImage)) { + return 1; + } + return Math.max(0, Math.min(2, this.scaleForeImage)); + } + + private get isDualScale(): boolean { + return this.type === ObcPitchRollHeaveType.dualScale; + } + + /** The centre readouts replace the vessels; never available in dual scale. */ + private get showsReadout(): boolean { + return this.hasReadout && !this.isDualScale; + } + + /** + * Pitch half-extent, capped so a roll arc of the minimum size still fits in + * the remaining quadrant. + */ + private get requestedPitchArcAngle(): number { + return Math.min( + 90 - MIN_ARC_HALF_DEG, + normalizeArcAngle(this.pitchArcAngle, 30) + ); + } + + /** + * Roll half-extent, clamped so the pitch and roll arcs cannot meet: the two + * diagonal gaps each span `90 - pitch - roll` degrees, so the pair has to + * stay under 90° combined. + */ + private get requestedRollArcAngle(): number { + const roll = normalizeArcAngle(this.rollArcAngle, 45); + const headroom = 90 - this.requestedPitchArcAngle; + return Math.max(MIN_ARC_HALF_DEG, Math.min(roll, headroom)); + } + + override render() { + const pitchReq = this.requestedPitchArcAngle; + const rollReq = this.requestedRollArcAngle; + const overlayViewBox = `-${CENTRE_HALF} -${CENTRE_HALF} ${CENTRE_HALF * 2} ${CENTRE_HALF * 2}`; + + const layout = this.zoomToFitArc + ? this.zoomLayout(pitchReq, rollReq) + : undefined; + const band: HeaveBand = layout?.band ?? { + outerR: OUTER_RING_RADIUS, + innerR: innerRingRadiusFor(WatchCircleType.double), + capRadius: OUTER_RING_RADIUS, + pitchDeg: pitchReq, + }; + + return html` +
+ + ${this.showsReadout ? nothing : this.renderCrosshair()} + ${this.renderOverlayVessels()} + ${this.zoomToFitArc || this.isDualScale + ? nothing + : this.renderComplement(pitchReq)} + ${this.renderHeaveColumn(band)} + + ${layout + ? this.renderZoomedArcs(layout) + : this.renderFullWatch(pitchReq, rollReq)} + ${this.showsReadout + ? html`
+ ${renderCenterReadouts( + [ + { + value: this.pitch, + label: this.pitchLabel, + unit: this.unit, + fractionDigits: this.fractionDigits, + size: ReadoutSize.large, + priority: this.priorityFor( + PitchRollHeavePriorityElement.pitch + ), + }, + { + value: this.roll, + label: this.rollLabel, + unit: this.unit, + fractionDigits: this.fractionDigits, + size: ReadoutSize.large, + priority: this.priorityFor( + PitchRollHeavePriorityElement.roll + ), + }, + { + value: this.heave, + label: this.heaveLabel, + unit: this.heaveUnit, + fractionDigits: this.fractionDigits, + size: ReadoutSize.large, + priority: this.priorityFor( + PitchRollHeavePriorityElement.heave + ), + }, + ], + CenterReadoutArrangement.stacked + )} +
` + : nothing} +
+ `; + } + + /** + * Crosshair drawn on the overlay rather than through `obc-watch`'s own + * `crosshairEnabled`: the design has the arc bands covering the arms, but + * `obc-watch` renders its crosshair after the bands, i.e. on top of them. + * Drawing it here puts it under the watch layer, so the bands and the heave + * column mask it naturally. + */ + private renderCrosshair(): SVGTemplateResult { + const r = OUTER_RING_RADIUS; + return svg` + + + `; + } + + /** + * Vessel silhouettes on the centre layer. Only needed under zoom — the + * un-zoomed path hands them to `obc-watch` via `vessels`, while the zoomed + * sub-watches have no room for them. + */ + private renderOverlayVessels(): SVGTemplateResult | typeof nothing { + if (!this.zoomToFitArc || this.isDualScale || this.showsReadout) { + return nothing; + } + const vesselScale = 224 / 160; + return svg` + + ${vesselImages[this.vesselImageSide]} + + + ${vesselImages[this.vesselImageFore]} + + `; + } + + /** + * Thin ring bridging the empty span across the top, from where the heave + * column's upper edge crosses the outer ring round to the pitch arc's start. + * Because the column height is derived from the pitch arc, both ends land at + * `±pitchArcAngle` off the vertical, making the ring symmetric. The two + * diagonal gaps lower down are left open by design. + */ + private renderComplement(pitchDeg: number): SVGTemplateResult { + const r = OUTER_RING_RADIUS; + const rad = (pitchDeg * Math.PI) / 180; + // Ring runs clockwise from (270 + pitchDeg) through 0 to (90 - pitchDeg). + const x1 = -r * Math.cos(rad); + const y1 = -r * Math.sin(rad); + const x2 = r * Math.cos(rad); + const y2 = -r * Math.sin(rad); + return svg` + + `; + } + + /** + * Linear heave scale, built from the same building block `obc-heave` uses. + * + * The column fills the band's radial slot, so its width equals the band + * thickness and its height keeps its top and bottom edges level with the + * pitch arc's end caps. In `single-scale` it sits in the left slot and is + * mirrored so the tick lane faces outward; in `dual-scale` it moves to the + * centre, unmirrored, and shrinks if the zoomed bands leave too little room. + */ + private renderHeaveColumn(band: HeaveBand): SVGTemplateResult { + const {width, height: naturalHeight} = heaveColumnSize(band); + let height = naturalHeight; + let offsetX = -(band.outerR + band.innerR) / 2; + + if (this.isDualScale) { + offsetX = 0; + // The centred column has to stay inside whatever the bands leave free. + // Only the height gives way: the column's thickness reads as a match for + // the band thickness, and shrinking both would break that. + const free = band.innerR - HEAVE_CENTRE_GAP_PX; + const halfWidth = width / 2; + const maxHalfHeight = + free > halfWidth ? Math.sqrt(free * free - halfWidth * halfWidth) : 0; + height = Math.min(height, 2 * maxHalfHeight); + } + + const scaleWidth = HEAVE_SCALE_WIDTH * Math.min(1, width / NOMINAL_BAND); + + const gauge = watchfaceLinear( + { + height, + width, + scaleWidth, + minValue: -this.heaveRange, + maxValue: this.heaveRange, + }, + [{min: this.minTrendHeave, max: this.maxTrendHeave}], + {value: this.heave}, + {container: 'var(--instrument-frame-primary-color)'}, + { + hideContainer: false, + off: false, + priority: this.priorityFor(PitchRollHeavePriorityElement.heave), + }, + { + mainTickmarks: [], + primaryTickmarkInterval: 0, + secondaryTickmarkInterval: linearTickInterval(height, this.heaveRange), + }, + this.heaveAdvices + ); + + const mirror = this.isDualScale ? '' : ' scale(-1, 1)'; + return svg`${gauge}`; + } + + private get heaveAdvices(): LinearAdviceRaw[] { + return this.heaveAdvice.map((advice) => { + const isActive = + this.maxTrendHeave >= advice.min && this.minTrendHeave <= advice.max; + const state = isActive + ? AdviceState.triggered + : advice.hinted + ? AdviceState.hinted + : AdviceState.regular; + return {...advice, state} satisfies LinearAdviceRaw; + }); + } + + /** + * Zoom-fit geometry, computed once per render and shared by the arc layer + * and the heave column. + * + * Frames are built at the REQUESTED half-extents with the same math as + * `obc-pitch` / `obc-roll`, so each sub-watch matches its standalone + * equivalent. Two clearance passes then shorten only the *rendered* arcs — + * the frames stay untouched, so band thickness, position and zoom level are + * preserved: + * + * 1. pitch vs roll at the diagonal, ratio-preserving, exactly as + * `obc-pitch-roll` does it; + * 2. roll vs the heave column, `single-scale` only, shortening the roll arc + * alone. Because the column's height follows the pitch arc, pass 1 already + * pulls the column in whenever pitch shrinks, so pitch needs no second + * pass. + */ + private zoomLayout(pitchReq: number, rollReq: number) { + const ext = 48; + const targetSize = (176 + ext) * 2; + const innerNat = innerRingRadiusFor(WatchCircleType.double); + const buildFrame = (halfDeg: number) => { + const areas: WatchArea[] = [ + { + startAngle: -halfDeg, + endAngle: halfDeg, + roundOutsideCut: true, + roundInsideCut: true, + }, + ]; + const baseFrame = computeZoomToFitArcFrame({ + areas, + outerRadius: OUTER_RING_RADIUS, + innerRadius: innerNat, + extension: ext, + targetSize, + }); + const subArcFrame = shiftArcFrameToOuterEdge( + baseFrame, + OUTER_RING_RADIUS + baseFrame.radiusOffset, + OUTER_RING_RADIUS, + CENTRE_HALF + ); + const scale = (CENTRE_HALF * 2) / subArcFrame.width; + const outerR = (OUTER_RING_RADIUS + baseFrame.radiusOffset) * scale; + const innerR = (innerNat + baseFrame.radiusOffset) * scale; + return {subArcFrame, outerR, innerR}; + }; + const pitchFrame = buildFrame(pitchReq); + const rollFrame = buildFrame(rollReq); + + const OR = OUTER_RING_RADIUS; + const toRad = (deg: number) => (deg * Math.PI) / 180; + const signedDist = ( + px: number, + py: number, + qx: number, + qy: number + ): number => { + const dx = qx - px; + const dy = qy - py; + const mag = Math.hypot(dx, dy); + return dx > 0 && dy > 0 ? mag : -mag; + }; + // Diagonal gap between two adjacent cardinal bands. All such gaps in this + // layout are congruent, so one pair stands in for the rest. + const minGap = (apRad: number, arRad: number): number => { + const cosP = Math.cos(apRad); + const sinP = Math.sin(apRad); + const cosR = Math.cos(arRad); + const sinR = Math.sin(arRad); + const p1x = pitchFrame.innerR * sinP; + const p1y = pitchFrame.outerR - OR - pitchFrame.innerR * cosP; + const p2x = pitchFrame.outerR * sinP; + const p2y = pitchFrame.outerR - OR - pitchFrame.outerR * cosP; + const q1x = OR - rollFrame.outerR + rollFrame.innerR * cosR; + const q1y = -rollFrame.innerR * sinR; + const q2x = OR - rollFrame.outerR + rollFrame.outerR * cosR; + const q2y = -rollFrame.outerR * sinR; + return Math.min( + signedDist(p1x, p1y, q1x, q1y), + signedDist(p2x, p2y, q2x, q2y) + ); + }; + + let aP = toRad(pitchReq); + let aR = toRad(rollReq); + if (minGap(aP, aR) < CORNER_GAP_PX) { + let lo = 0; + let hi = 1; + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2; + if (minGap(aP * mid, aR * mid) >= CORNER_GAP_PX) { + lo = mid; + } else { + hi = mid; + } + } + aP *= lo; + aR *= lo; + } + const pitchClampedDeg = Math.max(MIN_ARC_HALF_DEG, (aP * 180) / Math.PI); + let rollClampedDeg = Math.max(MIN_ARC_HALF_DEG, (aR * 180) / Math.PI); + + const bandFor = (pitchDeg: number): HeaveBand => ({ + outerR: OR, + innerR: OR - (pitchFrame.outerR - pitchFrame.innerR), + capRadius: pitchFrame.outerR, + pitchDeg, + }); + + if (!this.isDualScale) { + // Roll's left end caps versus the heave column rectangle. Shrinking the + // roll arc moves both caps toward the bottom cardinal, away from the + // column, so the gap grows monotonically as the scalar shrinks. + const band = bandFor(pitchClampedDeg); + const {width, height} = heaveColumnSize(band); + const x1 = -band.innerR; + const x0 = x1 - width; + const y1 = height / 2; + const heaveGap = (arRad: number): number => { + const cosR = Math.cos(arRad); + const sinR = Math.sin(arRad); + const outerX = -rollFrame.outerR * sinR; + const outerY = OR - rollFrame.outerR + rollFrame.outerR * cosR; + const innerX = -rollFrame.innerR * sinR; + const innerY = OR - rollFrame.outerR + rollFrame.innerR * cosR; + return Math.min( + distanceToRect(outerX, outerY, x0, -y1, x1, y1), + distanceToRect(innerX, innerY, x0, -y1, x1, y1) + ); + }; + if (heaveGap(toRad(rollClampedDeg)) < CORNER_GAP_PX) { + let lo = 0; + let hi = rollClampedDeg; + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2; + if (heaveGap(toRad(mid)) >= CORNER_GAP_PX) { + lo = mid; + } else { + hi = mid; + } + } + rollClampedDeg = Math.max(MIN_ARC_HALF_DEG, lo); + } + } + + return { + pitchFrame, + rollFrame, + pitchClampedDeg, + rollClampedDeg, + band: bandFor(pitchClampedDeg), + }; + } + + /** + * Zoomed-arc layer: CSS-rotated `` instances, each holding a + * single arc rendered at the watch's natural top and rotated onto its + * cardinal. `single-scale` places pitch right and roll bottom; `dual-scale` + * mirrors both. The heave column lives on the centre overlay. + */ + private renderZoomedArcs(layout: ReturnType) { + const {pitchFrame, rollFrame, pitchClampedDeg, rollClampedDeg} = layout; + + const subAreas = (halfDeg: number): WatchArea[] => [ + { + startAngle: -halfDeg, + endAngle: halfDeg, + roundOutsideCut: true, + roundInsideCut: true, + }, + ]; + + // Clip each sub-watch to the sector its (possibly shortened) arc covers so + // the indicator cannot leak past the visible band. + const sectorClip = ( + halfDeg: number, + frame: typeof rollFrame.subArcFrame + ): string => { + const oxPct = (-frame.x / frame.width) * 100; + const oyPct = (-frame.y / frame.height) * 100; + const dxPct = oyPct * Math.tan((halfDeg * Math.PI) / 180); + const lx = Math.max(0, oxPct - dxPct); + const rx = Math.min(100, oxPct + dxPct); + return `polygon(${oxPct}% ${oyPct}%, ${lx}% 0%, ${rx}% 0%)`; + }; + + const axis = ( + element: PitchRollHeavePriorityElement, + frame: typeof rollFrame, + halfDeg: number, + value: number, + avgMin: number, + avgMax: number, + max: number | undefined, + trigger: boolean, + cap: number + ) => ({ + arcFrame: frame.subArcFrame, + areas: subAreas(halfDeg), + barAreas: [ + { + startAngle: avgMin, + endAngle: avgMax, + fillColor: this.barColor(element), + }, + ], + needles: [ + { + angle: value, + fillColor: this.needleColor(element), + strokeColor: 'var(--border-silhouette-color)', + }, + ], + advices: this.subAdvices(halfDeg, max, trigger, cap), + tickmarks: [ + {angle: 0, type: TickmarkType.main}, + ...arcTickmarks(0, halfDeg), + ], + clipPath: sectorClip(halfDeg, frame.subArcFrame), + }); + + const pitchAxis = axis( + PitchRollHeavePriorityElement.pitch, + pitchFrame, + pitchClampedDeg, + this.pitch, + this.minAvgPitch, + this.maxAvgPitch, + this.maxPitchAdvice, + this.triggerPitchAdvice, + 30 + ); + const rollAxis = axis( + PitchRollHeavePriorityElement.roll, + rollFrame, + rollClampedDeg, + this.roll, + this.minAvgRoll, + this.maxAvgRoll, + this.maxRollAdvice, + this.triggerRollAdvice, + 45 + ); + + const subWatch = (rotation: number, a: typeof pitchAxis) => html` + + `; + + return html` + ${subWatch(90, pitchAxis)} ${subWatch(180, rollAxis)} + ${this.isDualScale + ? html`${subWatch(270, pitchAxis)} ${subWatch(0, rollAxis)}` + : nothing} + `; + } + + /** Caution bands for one sub-watch axis, at sub-watch-local angles. */ + private subAdvices( + halfDeg: number, + max: number | undefined, + trigger: boolean, + cap: number + ): AngleAdviceRaw[] { + if (max === undefined) return []; + const outer = Math.min(halfDeg, cap); + const inner = Math.min(max, outer); + const state = trigger ? AdviceState.triggered : AdviceState.regular; + return [ + { + minAngle: -outer, + maxAngle: -inner, + type: AdviceType.caution, + state, + hideMinTickmark: true, + }, + { + minAngle: inner, + maxAngle: outer, + type: AdviceType.caution, + state, + hideMaxTickmark: true, + }, + ]; + } + + /** Areas, tickmarks, bars and needles for the un-zoomed single watch. */ + private renderFullWatch(pitchDeg: number, rollDeg: number) { + const arc = (center: number, half: number): WatchArea => ({ + startAngle: center - half, + endAngle: center + half, + roundOutsideCut: true, + roundInsideCut: true, + }); + + const areas: WatchArea[] = [arc(90, pitchDeg), arc(180, rollDeg)]; + const tickmarks: Tickmark[] = [ + {angle: 90, type: TickmarkType.main}, + {angle: 180, type: TickmarkType.main}, + ...arcTickmarks(90, pitchDeg), + ...arcTickmarks(180, rollDeg), + ]; + const barAreas = [ + { + startAngle: 90 + this.minAvgPitch, + endAngle: 90 + this.maxAvgPitch, + fillColor: this.barColor(PitchRollHeavePriorityElement.pitch), + }, + { + startAngle: 180 + this.minAvgRoll, + endAngle: 180 + this.maxAvgRoll, + fillColor: this.barColor(PitchRollHeavePriorityElement.roll), + }, + ]; + const needles = [ + { + angle: 90 + this.pitch, + fillColor: this.needleColor(PitchRollHeavePriorityElement.pitch), + strokeColor: 'var(--border-silhouette-color)', + }, + { + angle: 180 + this.roll, + fillColor: this.needleColor(PitchRollHeavePriorityElement.roll), + strokeColor: 'var(--border-silhouette-color)', + }, + ]; + + if (this.isDualScale) { + areas.push(arc(270, pitchDeg), arc(0, rollDeg)); + tickmarks.push( + {angle: 270, type: TickmarkType.main}, + {angle: 0, type: TickmarkType.main}, + ...arcTickmarks(270, pitchDeg), + ...arcTickmarks(0, rollDeg) + ); + barAreas.push( + { + startAngle: 270 + this.minAvgPitch, + endAngle: 270 + this.maxAvgPitch, + fillColor: this.barColor(PitchRollHeavePriorityElement.pitch), + }, + { + startAngle: this.minAvgRoll, + endAngle: this.maxAvgRoll, + fillColor: this.barColor(PitchRollHeavePriorityElement.roll), + } + ); + needles.push( + { + angle: 270 + this.pitch, + fillColor: this.needleColor(PitchRollHeavePriorityElement.pitch), + strokeColor: 'var(--border-silhouette-color)', + }, + { + angle: this.roll, + fillColor: this.needleColor(PitchRollHeavePriorityElement.roll), + strokeColor: 'var(--border-silhouette-color)', + } + ); + } + + return html` + + `; + } + + private advices(pitchDeg: number, rollDeg: number): AngleAdviceRaw[] { + const advices: AngleAdviceRaw[] = []; + const push = ( + center: number, + halfDeg: number, + max: number | undefined, + trigger: boolean, + cap: number + ) => { + if (max === undefined) return; + const outer = Math.min(halfDeg, cap); + const inner = Math.min(max, outer); + const state = trigger ? AdviceState.triggered : AdviceState.regular; + advices.push({ + minAngle: center - outer, + maxAngle: center - inner, + type: AdviceType.caution, + state, + hideMinTickmark: true, + }); + advices.push({ + minAngle: center + inner, + maxAngle: center + outer, + type: AdviceType.caution, + state, + hideMaxTickmark: true, + }); + }; + push(90, pitchDeg, this.maxPitchAdvice, this.triggerPitchAdvice, 30); + push(180, rollDeg, this.maxRollAdvice, this.triggerRollAdvice, 45); + if (this.isDualScale) { + push(270, pitchDeg, this.maxPitchAdvice, this.triggerPitchAdvice, 30); + push(0, rollDeg, this.maxRollAdvice, this.triggerRollAdvice, 45); + } + return advices; + } + + static override styles = [ + centerReadoutStyles, + css` + * { + box-sizing: border-box; + } + + .container { + position: relative; + width: 100%; + height: 100%; + } + + .container > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .readout { + display: flex; + align-items: center; + justify-content: center; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'obc-pitch-roll-heave': ObcPitchRollHeave; + } +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll/pitch-roll.ts b/packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll/pitch-roll.ts index 2ac20461b..98e705582 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll/pitch-roll.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/pitch-roll/pitch-roll.ts @@ -10,11 +10,15 @@ import { innerRingRadiusFor, vesselImages, } from '../watch/watch.js'; -import {TickmarkType} from '../watch/tickmark.js'; +import {arcTickmarks, TickmarkType} from '../watch/tickmark.js'; import {AdviceState, AdviceType, AngleAdviceRaw} from '../watch/advice.js'; import {customElement} from '../../decorator.js'; import {Priority} from '../types.js'; -import {renderInstrumentReadout} from '../readout/instrument-readout.js'; +import { + centerReadoutStyles, + renderCenterReadouts, +} from '../readout/center-readout.js'; +import {ReadoutSize} from '../readout/readout.js'; import { computeZoomToFitArcFrame, normalizeArcAngle, @@ -214,41 +218,30 @@ export class ObcPitchRoll extends LitElement { : this.renderFullWatch(areas)} ${this.hasReadout ? html`
-
- ${this.renderReadout( - this.pitch, - this.pitchLabel, - PitchRollPriorityElement.pitch - )} -
- ${this.renderReadout( - this.roll, - this.rollLabel, - PitchRollPriorityElement.roll - )} -
+ ${renderCenterReadouts([ + { + value: this.pitch, + label: this.pitchLabel, + unit: this.unit, + fractionDigits: this.fractionDigits, + size: ReadoutSize.large, + priority: this.priorityFor(PitchRollPriorityElement.pitch), + }, + { + value: this.roll, + label: this.rollLabel, + unit: this.unit, + fractionDigits: this.fractionDigits, + size: ReadoutSize.large, + priority: this.priorityFor(PitchRollPriorityElement.roll), + }, + ])}
` : nothing}
`; } - private renderReadout( - value: number, - label: string, - element: PitchRollPriorityElement - ) { - return html` - ${renderInstrumentReadout({ - value, - priority: this.priorityFor(element), - label, - unit: this.unit, - fractionDigits: this.fractionDigits, - })} - `; - } - /** * Zoomed-arc layer: four CSS-rotated `` instances, each * containing a single arc rendered at the watch's natural top @@ -273,8 +266,6 @@ export class ObcPitchRoll extends LitElement { * unchanged. */ private renderZoomedArcs(pitchReq: number, rollReq: number) { - const tickmarks = [{angle: 0, type: TickmarkType.main}]; - // ---- Per-axis zoom-fit frames (requested half-extents) ------------- const ext = 48; const targetSize = (176 + ext) * 2; @@ -432,6 +423,17 @@ export class ObcPitchRoll extends LitElement { const pitchAdvices = this.subAdvices('pitch', pitchClampedDeg); const rollAdvices = this.subAdvices('roll', rollClampedDeg); + // Ladders span the CLAMPED extent so ticks never fall outside the band + // that is actually drawn (or outside the sector clip below). + const pitchTickmarks = [ + {angle: 0, type: TickmarkType.main}, + ...arcTickmarks(0, pitchClampedDeg), + ]; + const rollTickmarks = [ + {angle: 0, type: TickmarkType.main}, + ...arcTickmarks(0, rollClampedDeg), + ]; + // Clip each sub-watch to the angular sector actually covered by the // (possibly shortened) arc so the indicator pill and bar end-of-range // limit lines cannot leak past the visible band when the value falls @@ -500,7 +502,8 @@ export class ObcPitchRoll extends LitElement { barAreas: typeof rollBars, needles: typeof rollNeedles, advices: AngleAdviceRaw[], - clipPath: string + clipPath: string, + tickmarks: typeof rollTickmarks ) => html` @@ -747,44 +758,34 @@ export class ObcPitchRoll extends LitElement { return advices; } - static override styles = css` - * { - box-sizing: border-box; - } - - .container { - position: relative; - width: 100%; - height: 100%; - } - - .container > * { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } + static override styles = [ + centerReadoutStyles, + css` + * { + box-sizing: border-box; + } - .readout { - display: flex; - align-items: center; - justify-content: center; - } + .container { + position: relative; + width: 100%; + height: 100%; + } - .readout-group { - display: flex; - flex-direction: column; - align-items: center; - width: fit-content; - } + .container > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } - .readout-divider { - align-self: stretch; - height: 1px; - background: var(--border-divider-color); - } - `; + .readout { + display: flex; + align-items: center; + justify-content: center; + } + `, + ]; } declare global { diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts b/packages/openbridge-webcomponents/src/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts index 09507f674..e0af79044 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/rate-of-turn/rate-of-turn.stories.ts @@ -41,6 +41,13 @@ const meta: Meta = { barStartAngle: {control: {type: 'range', min: -180, max: 180, step: 1}}, barEndAngle: {control: {type: 'range', min: -180, max: 180, step: 1}}, width: {control: {type: 'range', min: 100, max: 1000, step: 1}}, + hasTrackBar: {control: 'boolean'}, + rotMaxValue: {control: {type: 'range', min: 1, max: 180, step: 1}}, + rotArcExtent: {control: {type: 'range', min: 10, max: 180, step: 5}}, + hasReadout: {control: 'boolean'}, + label: {control: 'text'}, + unit: {control: 'text'}, + fractionDigits: {control: 'number'}, dotColor: {table: {disable: true}}, barBgColor: {table: {disable: true}}, }, @@ -109,6 +116,49 @@ export const BarEnhancedInnerCirclePortStarboardPort: Story = { }, }; +export const WithReadout: Story = { + tags: ['6.1'], + args: { + rateOfTurnDegreesPerMinute: 20, + hasReadout: true, + }, +}; + +export const WithTrackBar: Story = { + tags: ['6.1'], + args: { + hasTrackBar: true, + hasReadout: true, + rateOfTurnDegreesPerMinute: 45, + rotPosition: RotPosition.innerCircle, + watchCircleType: WatchCircleType.double, + }, +}; + +export const WithTrackBarEnhanced: Story = { + tags: ['6.1'], + args: { + hasTrackBar: true, + hasReadout: true, + rateOfTurnDegreesPerMinute: 45, + rotPosition: RotPosition.innerCircle, + watchCircleType: WatchCircleType.double, + priority: Priority.enhanced, + }, +}; + +export const WithTrackBarPortStarboard: Story = { + tags: ['6.1'], + args: { + hasTrackBar: true, + hasReadout: true, + rateOfTurnDegreesPerMinute: 45, + rotPosition: RotPosition.innerCircle, + watchCircleType: WatchCircleType.double, + rotPortStarboard: true, + }, +}; + export const Demo: Story = { render: () => { const rateOfTurn = document.createElement('obc-rate-of-turn'); diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/rate-of-turn/rate-of-turn.ts b/packages/openbridge-webcomponents/src/navigation-instruments/rate-of-turn/rate-of-turn.ts index dabe3f179..4958de700 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/rate-of-turn/rate-of-turn.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/rate-of-turn/rate-of-turn.ts @@ -1,7 +1,14 @@ -import {LitElement, css, html} from 'lit'; +import {LitElement, css, html, nothing, unsafeCSS} from 'lit'; import {property} from 'lit/decorators.js'; import '../watch/watch.js'; import {WatchCircleType, RotType, RotPosition} from '../watch/watch.js'; +import {Tickmark, TickmarkType} from '../watch/tickmark.js'; +import { + centerReadoutStyles, + renderCenterReadouts, +} from '../readout/center-readout.js'; +import {ReadoutSize} from '../readout/readout.js'; +import instrumentReadoutStyle from '../readout/instrument-readout.css?inline'; import {ROT_ZERO_DEADBAND_DEG} from './rot-renderer.js'; import {customElement} from '../../decorator.js'; import {Priority} from '../types.js'; @@ -25,8 +32,15 @@ export {RotType, RotPosition}; * - **Track position**: Place the indicator on the outer scale ring * (`rotPosition="scale"`) or the inner circle * (`rotPosition="innerCircle"`). + * - **Track bar** (`hasTrackBar`): A bar in the ring band growing from the + * twelve o'clock position to the measured rate of turn, with a needle + * marker at its end and sector tickmarks (pairs with + * `watchCircleType="double"` for the banded face). + * - **Center readout** (`hasReadout`): A centered readout showing the + * measured rate of turn (label `ROT`, unit `DEG/min` by default). * - **Color priority**: Uses `priority` to select regular or enhanced color - * palette. + * palette; `rotPortStarboard` colors the track bar and needle by turn + * direction. * * ## Usage Guidelines * @@ -73,32 +87,148 @@ export class ObcRateOfTurn extends LitElement { WatchCircleType.single; @property({type: Boolean}) rotPortStarboard: boolean = false; @property({type: Number}) rotAtZeroDeadband: number = ROT_ZERO_DEADBAND_DEG; + /** + * When `true`, shows a bar in the ring band from the twelve o'clock + * position to the measured rate of turn, with a needle marker at its end + * and sector tickmarks. Pairs with `watchCircleType="double"` for the + * banded face. Driven by `rateOfTurnDegreesPerMinute` only. + */ + @property({type: Boolean}) hasTrackBar: boolean = false; + /** + * Bar-extent reference value in **degrees per minute**: the track bar + * reaches ±`rotArcExtent` when the measured ROT equals ±`rotMaxValue`. + * Default `60` aligns with ES-TRIN 2025/1 Art. 3.02. + * @availableWhen hasTrackBar==true + */ + @property({type: Number}) rotMaxValue: number = 60; + /** + * Arc extent of the track bar in degrees per direction. Default `60`. + * @availableWhen hasTrackBar==true + */ + @property({type: Number}) rotArcExtent: number = 60; + /** + * When `true`, shows a centered `` with the measured rate of + * turn. Shows a dash while `rateOfTurnDegreesPerMinute` is unset. + */ + @property({type: Boolean}) hasReadout: boolean = false; + /** + * Readout label. Default `ROT`. + * @availableWhen hasReadout==true + */ + @property({type: String}) label = 'ROT'; + /** + * Readout unit. Default `DEG/min`. + * @availableWhen hasReadout==true + */ + @property({type: String}) unit = 'DEG/min'; + /** + * Number of fraction digits shown in the readout. Default `0`. + * @availableWhen hasReadout==true + */ + @property({type: Number}) fractionDigits = 0; + + private get trackBarAngle(): number { + const max = this.rotMaxValue || 1; + const rot = this.rateOfTurnDegreesPerMinute ?? 0; + const ratio = Math.max(-1, Math.min(1, rot / max)); + return ratio * this.rotArcExtent; + } - static override styles = css` - * { - box-sizing: border-box; + private get trackBarColor(): string { + if (this.rotPortStarboard) { + const rot = this.rateOfTurnDegreesPerMinute ?? 0; + if (rot > 0) { + return 'var(--instrument-starboard-secondary-color)'; + } + if (rot < 0) { + return 'var(--instrument-port-secondary-color)'; + } } + return this.priority === Priority.enhanced + ? 'var(--instrument-enhanced-tertiary-color)' + : 'var(--instrument-regular-tertiary-color)'; + } - .container { - position: relative; - width: 100%; - height: 100%; + private get trackNeedleColor(): string { + if (this.rotPortStarboard) { + const rot = this.rateOfTurnDegreesPerMinute ?? 0; + if (rot > 0) { + return 'var(--instrument-starboard-primary-color)'; + } + if (rot < 0) { + return 'var(--instrument-port-primary-color)'; + } + return 'var(--instrument-regular-secondary-color)'; } + return this.priority === Priority.enhanced + ? 'var(--instrument-enhanced-secondary-color)' + : 'var(--instrument-regular-secondary-color)'; + } - .container > * { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; + private get trackTickmarks(): Tickmark[] { + const ticks: Tickmark[] = [{angle: 0, type: TickmarkType.main}]; + for (let angle = 30; angle <= this.rotArcExtent; angle += 30) { + ticks.push({angle, type: TickmarkType.primary}); + ticks.push({angle: -angle, type: TickmarkType.primary}); } - `; + return ticks; + } + + static override styles = [ + unsafeCSS(instrumentReadoutStyle), + centerReadoutStyles, + css` + * { + box-sizing: border-box; + } + + .container { + position: relative; + width: 100%; + height: 100%; + } + + .container > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .center-readout-overlay { + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + `, + ]; override render() { return html`
+ ${this.hasReadout + ? html`
+ ${renderCenterReadouts([ + { + value: this.rateOfTurnDegreesPerMinute ?? null, + label: this.label, + unit: this.unit, + fractionDigits: this.fractionDigits, + size: ReadoutSize.large, + priority: this.priority, + centerValue: true, + centerMeta: true, + }, + ])} +
` + : nothing}
`; } } diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/readout/center-readout.ts b/packages/openbridge-webcomponents/src/navigation-instruments/readout/center-readout.ts new file mode 100644 index 000000000..d2d8559a1 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/readout/center-readout.ts @@ -0,0 +1,196 @@ +import {CSSResult, TemplateResult, css, html, nothing} from 'lit'; +import {ReadoutPriority, ReadoutSize, ReadoutStacking} from './readout.js'; +import {renderInstrumentReadout} from './instrument-readout.js'; + +/** + * Shared center-readout cluster for radial instruments: one primary readout, + * optionally followed by a horizontal divider and a row of further readouts + * (the Figma "Center label" arrangements). The cluster is layout-only — + * positioning is left to the caller, matching {@link renderInstrumentReadout}. + * + * Consumers: `obc-compass` / `obc-heading` (source-bound entries via + * {@link resolveCompassCenterReadouts}), `obc-pitch-roll` and + * `obc-compass-sector` (pre-resolved entries). + */ + +/** Instrument datum a compass/heading center readout binds to. */ +export enum CompassReadoutSource { + hdg = 'hdg', + cog = 'cog', + rot = 'rot', +} + +/** + * One compass/heading center-readout configuration entry. The bound value, + * label/unit defaults (HDG/DEG, COG/DEG, ROT/°/min) and priority color are + * derived from `source`; `size` defaults to `large` for the first entry and + * `medium` for the rest. + */ +export interface CompassCenterReadout { + source: CompassReadoutSource; + label?: string; + unit?: string; + fractionDigits?: number; + size?: ReadoutSize; +} + +/** + * How the cluster arranges its entries. + */ +export enum CenterReadoutArrangement { + /** First entry on top, one divider, then the remaining entries side by side. */ + primarySecondary = 'primary-secondary', + /** Every entry stacked vertically, with a divider between each pair. */ + stacked = 'stacked', +} + +/** A resolved cluster entry, ready to render (source-agnostic). */ +export interface CenterReadoutEntry { + value: number | null; + label: string; + unit: string; + fractionDigits?: number; + size?: ReadoutSize; + priority?: ReadoutPriority; + centerValue?: boolean; + centerMeta?: boolean; +} + +const SOURCE_DEFAULTS: Record< + CompassReadoutSource, + {label: string; unit: string} +> = { + [CompassReadoutSource.hdg]: {label: 'HDG', unit: 'DEG'}, + [CompassReadoutSource.cog]: {label: 'COG', unit: 'DEG'}, + [CompassReadoutSource.rot]: {label: 'ROT', unit: '°/min'}, +}; + +/** + * Resolves compass/heading `centerReadouts` entries to renderable cluster + * entries: binds values (`hdg` → heading, `cog` → course over ground, + * `rot` → rate of turn, dash when unavailable), fills label/unit defaults and + * derives the per-entry priority color via the instrument's per-element + * priority model. + */ +export function resolveCompassCenterReadouts( + readouts: CompassCenterReadout[], + ctx: { + heading: number; + courseOverGround: number; + rateOfTurnDegreesPerMinute?: number; + priorityFor: (source: CompassReadoutSource) => ReadoutPriority; + } +): CenterReadoutEntry[] { + return readouts.map((readout, index) => { + const defaults = SOURCE_DEFAULTS[readout.source]; + let value: number | null; + switch (readout.source) { + case CompassReadoutSource.hdg: + value = ctx.heading; + break; + case CompassReadoutSource.cog: + value = ctx.courseOverGround; + break; + case CompassReadoutSource.rot: + value = ctx.rateOfTurnDegreesPerMinute ?? null; + break; + } + return { + value, + label: readout.label ?? defaults.label, + unit: readout.unit ?? defaults.unit, + fractionDigits: readout.fractionDigits ?? 0, + size: + readout.size ?? (index === 0 ? ReadoutSize.large : ReadoutSize.medium), + priority: ctx.priorityFor(readout.source), + }; + }); +} + +function renderEntry(entry: CenterReadoutEntry): TemplateResult { + const size = entry.size ?? ReadoutSize.large; + return renderInstrumentReadout({ + value: entry.value ?? undefined, + label: entry.label, + unit: entry.unit, + fractionDigits: entry.fractionDigits, + priority: entry.priority, + size, + stacking: + size === ReadoutSize.large + ? ReadoutStacking.inline + : ReadoutStacking.stacked, + centerValue: entry.centerValue ?? false, + centerMeta: entry.centerMeta ?? false, + }); +} + +/** + * Renders the cluster. + * + * `primarySecondary` (default): first entry on top; when more entries exist, a + * stretched 1px divider followed by the remaining entries side by side. + * + * `stacked`: every entry on its own row, separated by a stretched 1px divider — + * the arrangement `obc-pitch-roll-heave` uses for its Pitch / Roll / Heave + * column. + */ +export function renderCenterReadouts( + entries: CenterReadoutEntry[], + arrangement: CenterReadoutArrangement = CenterReadoutArrangement.primarySecondary +): TemplateResult { + const [first, ...rest] = entries; + if (!first) { + return html`${nothing}`; + } + if (arrangement === CenterReadoutArrangement.stacked) { + return html` +
+ ${renderEntry(first)} + ${rest.map( + (entry) => html` +
+ ${renderEntry(entry)} + ` + )} +
+ `; + } + return html` +
+ ${renderEntry(first)} + ${rest.length > 0 + ? html` +
+
+ ${rest.map((entry) => renderEntry(entry))} +
+ ` + : nothing} +
+ `; +} + +/** Style fragment for the cluster; append to the consumer's static styles. */ +export const centerReadoutStyles: CSSResult = css` + .center-readout-group { + display: flex; + flex-direction: column; + align-items: center; + width: fit-content; + gap: 8px; + } + + .center-readout-divider { + align-self: stretch; + height: 1px; + background: var(--border-divider-color); + } + + .center-readout-secondary-row { + display: flex; + align-items: flex-start; + justify-content: center; + gap: 8px; + } +`; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/rot-linear/rot-linear.css b/packages/openbridge-webcomponents/src/navigation-instruments/rot-linear/rot-linear.css new file mode 100644 index 000000000..2e343deb2 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/rot-linear/rot-linear.css @@ -0,0 +1,48 @@ +* { + box-sizing: border-box; +} + +.container { + position: relative; + width: 100%; + height: 100%; + max-width: 512px; +} + +.container > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +:host { + display: grid; + place-items: center; + grid-template-rows: 100%; + grid-template-columns: 100%; + width: 100%; + height: 100%; +} + +.with-readout { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + min-height: 0; +} + +.with-readout .container { + flex: 0 1 auto; + min-height: 0; + height: auto; + aspect-ratio: 3 / 1; +} + +.flat-readout { + flex: 0 0 auto; +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/rot-linear/rot-linear.stories.ts b/packages/openbridge-webcomponents/src/navigation-instruments/rot-linear/rot-linear.stories.ts new file mode 100644 index 000000000..697474fbd --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/rot-linear/rot-linear.stories.ts @@ -0,0 +1,76 @@ +import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import {ObcRotLinear} from './rot-linear.js'; +import './rot-linear.js'; +import {Priority} from '../types.js'; +import {widthDecorator} from '../../storybook-util.js'; + +const meta: Meta = { + title: 'Instruments/Rot Linear', + tags: ['autodocs', '6.1'], + component: 'obc-rot-linear', + args: { + width: 512, + height: 170, + rateOfTurnDegreesPerMinute: 45, + rotMaxValue: 90, + tickInterval: 5, + rotPortStarboard: false, + priority: Priority.regular, + hasReadout: false, + }, + argTypes: { + width: {control: {type: 'range', min: 100, max: 1028, step: 1}}, + height: {control: {type: 'range', min: 60, max: 512, step: 1}}, + rateOfTurnDegreesPerMinute: { + control: {type: 'range', min: -120, max: 120, step: 1}, + }, + rotMaxValue: {control: {type: 'range', min: 10, max: 180, step: 5}}, + tickInterval: {control: {type: 'range', min: 1, max: 45, step: 1}}, + rotPortStarboard: {control: 'boolean'}, + priority: {control: 'select', options: Object.values(Priority)}, + hasReadout: {control: 'boolean'}, + label: {control: 'text'}, + unit: {control: 'text'}, + fractionDigits: {control: 'number'}, + }, + decorators: [widthDecorator], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Enhanced: Story = { + args: { + priority: Priority.enhanced, + }, +}; + +export const PortStarboard: Story = { + args: { + rotPortStarboard: true, + }, +}; + +export const PortStarboardPort: Story = { + args: { + rotPortStarboard: true, + rateOfTurnDegreesPerMinute: -45, + }, +}; + +export const WithReadout: Story = { + args: { + hasReadout: true, + height: 230, + }, +}; + +export const WithReadoutEnhanced: Story = { + args: { + hasReadout: true, + priority: Priority.enhanced, + height: 230, + }, +}; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/rot-linear/rot-linear.ts b/packages/openbridge-webcomponents/src/navigation-instruments/rot-linear/rot-linear.ts new file mode 100644 index 000000000..1b68c6d1f --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/rot-linear/rot-linear.ts @@ -0,0 +1,234 @@ +import {LitElement, html, unsafeCSS} from 'lit'; +import componentStyle from './rot-linear.css?inline'; +import instrumentReadoutStyle from '../readout/instrument-readout.css?inline'; +import {property} from 'lit/decorators.js'; +import '../watch-flat/watch-flat.js'; +import {Tickmark, TickmarkType} from '../watch-flat/tickmark-flat.js'; +import {Label, LabelPosition} from '../compass-flat/compass-flat.js'; +import { + centerReadoutStyles, + renderCenterReadouts, +} from '../readout/center-readout.js'; +import {ReadoutSize} from '../readout/readout.js'; +import {customElement} from '../../decorator.js'; +import {Priority} from '../types.js'; + +/** Width of the strip content in SVG user-space units (watch-flat default). */ +const STRIP_WIDTH = 352; + +/** + * `` — Horizontal rate-of-turn strip with a bar growing from + * the center of the track band. + * + * The linear counterpart of the `` track bar and the + * rate-of-turn sibling of ``: a static (non-scrolling) + * strip with a numeric scale on top and a track band in which a filled bar + * grows from the center (zero) to the measured rate of turn, ending in a + * needle marker. + * + * ## Features + * + * - **Track bar**: A bar from the band center to the measured value with a + * needle marker at its end; the value is clamped to ±`rotMaxValue`. + * - **Numeric scale**: Labels at zero, ±half range and ±full range, with + * tickmarks every `tickInterval` units. + * - **Readout** (`hasReadout`): A centered readout below the strip (label + * `ROT`, unit `DEG/min` by default); shows a dash while + * `rateOfTurnDegreesPerMinute` is unset. + * - **Coloring**: `rotPortStarboard` colors the bar and needle by turn + * direction; otherwise `priority` selects the regular or enhanced palette. + * + * ## Usage Guidelines + * + * - Set `rateOfTurnDegreesPerMinute` to the measured value in degrees per + * minute (positive = starboard). + * - Use `` for the circular presentation of the same + * indication. + * + * @ignition-base-height: 170px + * @ignition-base-width: 512px + * @stable + */ +@customElement('obc-rot-linear') +export class ObcRotLinear extends LitElement { + /** + * Measured rate of turn in degrees per minute (positive = starboard). + * When unset, no bar is shown and the readout shows a dash. + */ + @property({type: Number}) rateOfTurnDegreesPerMinute: number | undefined; + /** Scale range in **degrees per minute** per direction. Default `90`. */ + @property({type: Number}) rotMaxValue: number = 90; + /** Interval for tickmarks in scale units. Default `5`. */ + @property({type: Number}) tickInterval = 5; + /** Colors the bar and needle by turn direction (starboard/port palette). */ + @property({type: Boolean}) rotPortStarboard: boolean = false; + @property({type: String}) priority: Priority = Priority.regular; + /** + * When `true`, shows a centered `` below the strip with the + * measured rate of turn. + */ + @property({type: Boolean}) hasReadout: boolean = false; + /** + * Readout label. Default `ROT`. + * @availableWhen hasReadout==true + */ + @property({type: String}) label = 'ROT'; + /** + * Readout unit. Default `DEG/min`. + * @availableWhen hasReadout==true + */ + @property({type: String}) unit = 'DEG/min'; + /** + * Number of fraction digits shown in the readout. Default `0`. + * @availableWhen hasReadout==true + */ + @property({type: Number}) fractionDigits = 0; + + /** `rotMaxValue` guarded against non-finite or non-positive values. */ + private get safeMaxValue(): number { + return Number.isFinite(this.rotMaxValue) && this.rotMaxValue > 0 + ? this.rotMaxValue + : 90; + } + + private get pxPerUnit(): number { + return STRIP_WIDTH / 2 / this.safeMaxValue; + } + + private get barEndX(): number { + const rot = this.rateOfTurnDegreesPerMinute ?? 0; + const ratio = Math.max(-1, Math.min(1, rot / this.safeMaxValue)); + return ratio * (STRIP_WIDTH / 2); + } + + private get barColor(): string { + if (this.rotPortStarboard) { + const rot = this.rateOfTurnDegreesPerMinute ?? 0; + if (rot > 0) { + return 'var(--instrument-starboard-secondary-color)'; + } + if (rot < 0) { + return 'var(--instrument-port-secondary-color)'; + } + } + return this.priority === Priority.enhanced + ? 'var(--instrument-enhanced-tertiary-color)' + : 'var(--instrument-regular-tertiary-color)'; + } + + private get needleColor(): string { + if (this.rotPortStarboard) { + const rot = this.rateOfTurnDegreesPerMinute ?? 0; + if (rot > 0) { + return 'var(--instrument-starboard-primary-color)'; + } + if (rot < 0) { + return 'var(--instrument-port-primary-color)'; + } + return 'var(--instrument-regular-secondary-color)'; + } + return this.priority === Priority.enhanced + ? 'var(--instrument-enhanced-secondary-color)' + : 'var(--instrument-regular-secondary-color)'; + } + + private get scaleLabels(): Label[] { + const max = this.safeMaxValue; + const values = [-max, -max / 2, 0, max / 2, max]; + return values.map((value) => ({ + x: value * this.pxPerUnit, + y: LabelPosition.top, + text: `${Math.round(value)}`, + })); + } + + private get scaleTickmarks(): Tickmark[] { + const tickmarks: Tickmark[] = []; + const labelXs = new Set(this.scaleLabels.map((l) => Math.round(l.x))); + + for (const label of this.scaleLabels) { + tickmarks.push({angle: label.x, type: TickmarkType.main}); + } + + if ( + !this.tickInterval || + this.tickInterval <= 0 || + !Number.isFinite(this.tickInterval) + ) { + return tickmarks; + } + + for ( + let value = -this.safeMaxValue; + value <= this.safeMaxValue; + value += this.tickInterval + ) { + const x = value * this.pxPerUnit; + if (labelXs.has(Math.round(x))) { + continue; + } + tickmarks.push({angle: x, type: TickmarkType.secondary}); + } + + return tickmarks; + } + + override render() { + const hasValue = this.rateOfTurnDegreesPerMinute !== undefined; + const strip = html` +
+ +
+ `; + + if (!this.hasReadout) { + return strip; + } + return html` +
+ ${strip} +
+ ${renderCenterReadouts([ + { + value: this.rateOfTurnDegreesPerMinute ?? null, + label: this.label, + unit: this.unit, + fractionDigits: this.fractionDigits, + size: ReadoutSize.large, + priority: this.priority, + centerValue: true, + centerMeta: true, + }, + ])} +
+
+ `; + } + + static override styles = [ + unsafeCSS(instrumentReadoutStyle), + centerReadoutStyles, + unsafeCSS(componentStyle), + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'obc-rot-linear': ObcRotLinear; + } +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/speed-arrows/speed-arrows-art.ts b/packages/openbridge-webcomponents/src/navigation-instruments/speed-arrows/speed-arrows-art.ts new file mode 100644 index 000000000..c6fc9127b --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/speed-arrows/speed-arrows-art.ts @@ -0,0 +1,35 @@ +/** + * Shared chevron art for stepped speed indicators. + * + * All paths are authored on a 96x96 canvas with the chevrons pointing up and + * stacked in three horizontal bands; consumers place them with their own + * `translate`/`rotate`/`scale` transforms (`obc-speed-arrows` rotates the + * whole 96 box per direction, `obc-speed-directions` scales the box into its + * placement cells and bar tips). + * + * `renderChevronBand(path, fill, stroke)` renders one band as the canonical + * two-path fragment: a filled path plus a non-scaling-stroke outline path + * with square line caps — the exact markup `obc-speed-arrows` historically + * inlined, so its rendering stays byte-identical. + */ +import {svg, SVGTemplateResult} from 'lit'; + +/** + * Chevron band paths of the 96x96 speed-arrows canvas, innermost band first + * (band 0 is closest to the vessel when pointing away from it). + */ +export const CHEVRON_PATHS = [ + 'M48.0004 56L76.0004 72L76.0004 88L48.0004 72L20 88L20.0004 72L48.0004 56Z', + 'M76.0004 48L48.0004 32L20.0004 48L20 64L48.0004 48L76.0004 64L76.0004 48Z', + 'M76.0004 24L48.0004 8L20.0004 24L20 40L48.0004 24L76.0004 40L76.0004 24Z', +] as const; + +export function renderChevronBand( + path: string, + fill: string, + stroke: string +): SVGTemplateResult { + return svg` + +`; +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/speed-arrows/speed-arrows.ts b/packages/openbridge-webcomponents/src/navigation-instruments/speed-arrows/speed-arrows.ts index 1b05dce71..dace655e7 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/speed-arrows/speed-arrows.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/speed-arrows/speed-arrows.ts @@ -1,10 +1,11 @@ -import {LitElement, html, nothing, svg, unsafeCSS} from 'lit'; +import {LitElement, html, nothing, unsafeCSS} from 'lit'; import {property} from 'lit/decorators.js'; import compentStyle from './speed-arrows.css?inline'; import '../readout/readout.js'; import {ReadoutDirection, ReadoutSize} from '../readout/readout.js'; import {Priority} from '../types.js'; import {customElement} from '../../decorator.js'; +import {CHEVRON_PATHS, renderChevronBand} from './speed-arrows-art.js'; export enum Direction { forward = 'forward', @@ -161,19 +162,25 @@ export class ObcSpeedArrows extends LitElement { > ${this.tintedArrows || this.nActiveArrows > 0 - ? svg` - 0, colors)}/> - 0, colors)} stroke-linecap="square"/>` + ? renderChevronBand( + CHEVRON_PATHS[0], + this.getFillColor(this.nActiveArrows > 0, colors), + this.getStrokeColor(this.nActiveArrows > 0, colors) + ) : nothing} ${this.tintedArrows || this.nActiveArrows > 1 - ? svg` - 1, colors)}/> - 1, colors)} stroke-linecap="square"/>` + ? renderChevronBand( + CHEVRON_PATHS[1], + this.getFillColor(this.nActiveArrows > 1, colors), + this.getStrokeColor(this.nActiveArrows > 1, colors) + ) : nothing} ${this.tintedArrows || this.nActiveArrows > 2 - ? svg` - 2, colors)}/> - 2, colors)} stroke-linecap="square"/>` + ? renderChevronBand( + CHEVRON_PATHS[2], + this.getFillColor(this.nActiveArrows > 2, colors), + this.getStrokeColor(this.nActiveArrows > 2, colors) + ) : nothing} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions-art.ts b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions-art.ts new file mode 100644 index 000000000..17b6be168 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions-art.ts @@ -0,0 +1,85 @@ +import {svg, nothing, SVGTemplateResult} from 'lit'; +import { + CHEVRON_PATHS, + renderChevronBand, +} from '../speed-arrows/speed-arrows-art.js'; +import { + BAR_WIDTH, + AXIS_LINE_HALF, + CellSpec, +} from './speed-directions-geometry.js'; + +const ACTIVE_FILL = 'var(--instrument-enhanced-secondary-color)'; +const ART_STROKE = 'var(--instrument-frame-secondary-color)'; +const INACTIVE_FILL = 'var(--instrument-frame-secondary-color)'; +const INACTIVE_STROKE = 'var(--border-outline-color)'; +const BAR_FILL = 'var(--instrument-enhanced-tertiary-color)'; + +function chevronBands(count: number, tinted: boolean): SVGTemplateResult[] { + const bands: SVGTemplateResult[] = []; + for (let i = 0; i < 3; i++) { + if (tinted || i < count) { + const active = i < count; + bands.push( + renderChevronBand( + CHEVRON_PATHS[i], + active ? ACTIVE_FILL : INACTIVE_FILL, + active ? ART_STROKE : INACTIVE_STROKE + ) + ); + } + } + return bands; +} + +/* Bar-tip icons are top-anchored in their box (Figma "speed-arrows-low/ + medium" 48-box assets): the topmost band straddles the bar tip. */ +function tipChevronBands(count: number): SVGTemplateResult[] { + const bands: SVGTemplateResult[] = []; + for (let i = 2; i > 2 - count; i--) { + bands.push(renderChevronBand(CHEVRON_PATHS[i], ACTIVE_FILL, ART_STROKE)); + } + return bands; +} + +/** Chevron stack in a placement cell; the 96-box art is scaled to the cell. */ +export function renderSpeedChevrons( + cell: CellSpec, + count: 0 | 1 | 2 | 3, + tinted: boolean +): SVGTemplateResult | typeof nothing { + if (count === 0 && !tinted) return nothing; + const s = cell.size / 96; + return svg`${chevronBands(count, tinted)}`; +} + +/** + * Proportional bar with chevron tip, drawn pointing "up" in local coordinates + * from (0,0) and placed via translate+rotate. Tip chevron box (48) top edge + * sits 12 units beyond the bar end (Figma extraction). + */ +export function renderSpeedBar( + originX: number, + originY: number, + rotationDeg: number, + lengthUnits: number, + tipChevrons: 0 | 1 | 2 | 3 +): SVGTemplateResult | typeof nothing { + if (lengthUnits <= 0 || tipChevrons === 0) return nothing; + const chevronScale = 48 / 96; + return svg` + + ${tipChevronBands(tipChevrons)} + `; +} + +/** Thin axis line through/parallel to the center, tick-mark tertiary color. */ +export function renderAxisLine( + orientation: 'h' | 'v', + offset: number, + half: number = AXIS_LINE_HALF +): SVGTemplateResult { + return orientation === 'h' + ? svg`` + : svg``; +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions-geometry.spec.ts b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions-geometry.spec.ts new file mode 100644 index 000000000..537be3008 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions-geometry.spec.ts @@ -0,0 +1,106 @@ +import {describe, expect, it} from 'vitest'; +import { + SpeedDirectionsType, + SpeedDirectionsFrameStyle, + speedSteps, + barLengthUnits, + chevronCell, + chordHalfLength, + BAR_MAX_LENGTH, +} from './speed-directions-geometry.js'; + +describe('chordHalfLength', () => { + it('is the radius at center and shrinks toward the edge', () => { + expect(chordHalfLength(160, 0)).toBe(160); + expect(chordHalfLength(160, 48)).toBeCloseTo( + Math.sqrt(160 * 160 - 48 * 48) + ); + expect(chordHalfLength(160, 160)).toBe(0); + expect(chordHalfLength(160, 200)).toBe(0); + }); +}); + +describe('speedSteps', () => { + it('maps knots to 0-3 chevrons via ceil(|v|/step)', () => { + expect(speedSteps(0, 3)).toBe(0); + expect(speedSteps(0.1, 3)).toBe(1); + expect(speedSteps(3, 3)).toBe(1); + expect(speedSteps(3.1, 3)).toBe(2); + expect(speedSteps(-7, 3)).toBe(3); + expect(speedSteps(99, 3)).toBe(3); + }); + it('guards invalid input', () => { + expect(speedSteps(NaN, 3)).toBe(0); + expect(speedSteps(5, 0)).toBe(0); + expect(speedSteps(5, -1)).toBe(0); + expect(speedSteps(Infinity, 3)).toBe(0); + }); +}); + +describe('barLengthUnits', () => { + it('is proportional and clamped', () => { + expect(barLengthUnits(0, 9)).toBe(0); + expect(barLengthUnits(4.5, 9)).toBeCloseTo(BAR_MAX_LENGTH / 2); + expect(barLengthUnits(-9, 9)).toBe(BAR_MAX_LENGTH); + expect(barLengthUnits(20, 9)).toBe(BAR_MAX_LENGTH); + }); + it('guards invalid input', () => { + expect(barLengthUnits(NaN, 9)).toBe(0); + expect(barLengthUnits(5, 0)).toBe(0); + }); +}); + +describe('chevronCell', () => { + const {alongAthwartArrows, longLatArrows} = SpeedDirectionsType; + const {standalone, framed, compass} = SpeedDirectionsFrameStyle; + it('is symmetric fore/aft and stbd/port', () => { + const fore = chevronCell(alongAthwartArrows, standalone, 'along', true); + const aft = chevronCell(alongAthwartArrows, standalone, 'along', false); + expect(fore.cy).toBe(-aft.cy); + expect(fore.rotationDeg).toBe(0); + expect(aft.rotationDeg).toBe(180); + const stbd = chevronCell(alongAthwartArrows, framed, 'athwartBow', true); + const port = chevronCell(alongAthwartArrows, framed, 'athwartBow', false); + expect(stbd.cx).toBe(-port.cx); + expect(stbd.rotationDeg).toBe(90); + expect(port.rotationDeg).toBe(270); + }); + it('framed and compass share cells', () => { + expect(chevronCell(longLatArrows, framed, 'along', true)).toEqual( + chevronCell(longLatArrows, compass, 'along', true) + ); + }); + it('keeps the standalone midship row at its Figma bow-ward offset', () => { + expect( + chevronCell( + SpeedDirectionsType.longLatArrows, + standalone, + 'athwartMid', + true + ) + ).toEqual({cx: 76, cy: -3, size: 64, rotationDeg: 90}); + }); + it('matches Figma extractions', () => { + expect( + chevronCell(alongAthwartArrows, standalone, 'athwartBow', true) + ).toEqual({cx: 76, cy: -108, size: 64, rotationDeg: 90}); + expect(chevronCell(alongAthwartArrows, framed, 'along', true)).toEqual({ + cx: 0, + cy: -96, + size: 48, + rotationDeg: 0, + }); + expect(chevronCell(longLatArrows, framed, 'athwartMid', true)).toEqual({ + cx: 72, + cy: 0, + size: 96, + rotationDeg: 90, + }); + expect(chevronCell(longLatArrows, standalone, 'along', true)).toEqual({ + cx: 0, + cy: -80, + size: 64, + rotationDeg: 0, + }); + }); +}); diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions-geometry.ts b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions-geometry.ts new file mode 100644 index 000000000..0355a0369 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions-geometry.ts @@ -0,0 +1,132 @@ +export enum SpeedDirectionsType { + alongAthwartArrows = 'alongAthwartArrows', + alongAthwartBars = 'alongAthwartBars', + longLatArrows = 'longLatArrows', +} + +export enum SpeedDirectionsFrameStyle { + standalone = 'standalone', + framed = 'framed', + compass = 'compass', +} + +// eslint-disable-next-line openbridge/prefer-enum-over-string-literal-union +export type SpeedAxis = 'along' | 'athwartBow' | 'athwartStern' | 'athwartMid'; + +export interface CellSpec { + cx: number; + cy: number; + size: number; + rotationDeg: number; +} + +export const BAR_MAX_LENGTH = 160; +export const BAR_WIDTH = 28; +export const ATHWART_AXIS_OFFSET = 48; +export const VESSEL_CENTER_Y = 2; +export const FRAME_HALF = 168; +export const AXIS_LINE_HALF = 168; +export const FLAT_VIEWBOX = '-192 -192 384 384'; + +export function speedSteps( + speedKnots: number, + stepKnots: number +): 0 | 1 | 2 | 3 { + if ( + !Number.isFinite(speedKnots) || + !Number.isFinite(stepKnots) || + stepKnots <= 0 + ) { + return 0; + } + return Math.min(Math.ceil(Math.abs(speedKnots) / stepKnots), 3) as + | 0 + | 1 + | 2 + | 3; +} + +/** Half-length of a circle chord drawn at the given offset from center. */ +export function chordHalfLength(radius: number, offset: number): number { + return Math.sqrt(Math.max(radius * radius - offset * offset, 0)); +} + +export function barLengthUnits(speedKnots: number, maxKnots: number): number { + if ( + !Number.isFinite(speedKnots) || + !Number.isFinite(maxKnots) || + maxKnots <= 0 + ) { + return 0; + } + return Math.min(Math.abs(speedKnots) / maxKnots, 1) * BAR_MAX_LENGTH; +} + +// Cell centers/sizes extracted from Figma (see plan Appendix A). +// "distance" entries are measured from the vessel center along the axis. +interface CellTableEntry { + size: number; + alongCenter: number; // |cy| of the along cells + athwartCenter: number; // |cx| of the athwart cells + // |cy| of the bow/stern athwart rows; for athwartMid it is the small + // bow-ward offset of the midship row (rendered as -athwartRowY), taken + // from the Figma extraction (3 in the standalone frame style, 0 in-frame). + athwartRowY: number; +} + +const CELL_TABLE: Record< + SpeedDirectionsType, + Record<'standalone' | 'inFrame', CellTableEntry> +> = { + [SpeedDirectionsType.alongAthwartArrows]: { + standalone: { + size: 64, + alongCenter: 32, + athwartCenter: 76, + athwartRowY: 108, + }, + inFrame: {size: 48, alongCenter: 96, athwartCenter: 48, athwartRowY: 48}, + }, + [SpeedDirectionsType.alongAthwartBars]: { + // bars use the same tip cells as arrows in-frame; standalone bars have no chevron cells + standalone: {size: 48, alongCenter: 96, athwartCenter: 48, athwartRowY: 48}, + inFrame: {size: 48, alongCenter: 96, athwartCenter: 48, athwartRowY: 48}, + }, + [SpeedDirectionsType.longLatArrows]: { + standalone: {size: 64, alongCenter: 80, athwartCenter: 76, athwartRowY: 3}, + inFrame: {size: 96, alongCenter: 112, athwartCenter: 72, athwartRowY: 0}, + }, +}; + +export function chevronCell( + type: SpeedDirectionsType, + frameStyle: SpeedDirectionsFrameStyle, + axis: SpeedAxis, + positive: boolean +): CellSpec { + const variant = + frameStyle === SpeedDirectionsFrameStyle.standalone + ? 'standalone' + : 'inFrame'; + const t = CELL_TABLE[type][variant]; + if (axis === 'along') { + return { + cx: 0, + cy: positive ? -t.alongCenter : t.alongCenter, + size: t.size, + rotationDeg: positive ? 0 : 180, + }; + } + const rowSign = axis === 'athwartStern' ? 1 : axis === 'athwartBow' ? -1 : 0; + return { + cx: positive ? t.athwartCenter : -t.athwartCenter, + cy: + rowSign === 0 + ? t.athwartRowY === 0 + ? 0 + : -t.athwartRowY + : rowSign * t.athwartRowY, + size: t.size, + rotationDeg: positive ? 90 : 270, + }; +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions.css b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions.css new file mode 100644 index 000000000..69ba97b42 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions.css @@ -0,0 +1,19 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.container { + position: relative; + width: 100%; + height: 100%; +} + +.container > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions.stories.ts b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions.stories.ts new file mode 100644 index 000000000..7bf49d4f8 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions.stories.ts @@ -0,0 +1,158 @@ +import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import {ObcSpeedDirections} from './speed-directions.js'; +import './speed-directions.js'; +import {widthDecorator} from '../../storybook-util.js'; +import {topVessels} from '../watch/vessels/storybook-helper.js'; +import { + SpeedDirectionsType, + SpeedDirectionsFrameStyle, +} from './speed-directions-geometry.js'; + +const meta: Meta = { + title: 'Instruments/Speed Directions', + tags: ['autodocs', '6.0', 'wip'], + component: 'obc-speed-directions', + args: { + width: 384, + type: SpeedDirectionsType.alongAthwartArrows, + frameStyle: SpeedDirectionsFrameStyle.standalone, + }, + argTypes: { + width: {control: {type: 'range', min: 100, max: 1000, step: 1}}, + type: {control: 'select', options: Object.values(SpeedDirectionsType)}, + frameStyle: { + control: 'select', + options: Object.values(SpeedDirectionsFrameStyle), + }, + speedAlongKnots: {control: {type: 'range', min: -12, max: 12, step: 0.1}}, + speedAthwartBowKnots: { + control: {type: 'range', min: -4, max: 4, step: 0.1}, + }, + speedAthwartSternKnots: { + control: {type: 'range', min: -4, max: 4, step: 0.1}, + }, + speedAthwartKnots: {control: {type: 'range', min: -4, max: 4, step: 0.1}}, + vesselImage: {control: 'select', options: topVessels}, + }, + decorators: [widthDecorator], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AlongAthwartArrowsStandalone: Story = { + args: { + speedAlongKnots: 4.2, + speedAthwartBowKnots: 0.8, + speedAthwartSternKnots: -0.6, + }, +}; + +export const LongLatArrowsStandalone: Story = { + args: { + type: SpeedDirectionsType.longLatArrows, + speedAlongKnots: 4.2, + speedAthwartKnots: 0.8, + }, +}; + +export const AlongAthwartBarsStandalone: Story = { + args: { + type: SpeedDirectionsType.alongAthwartBars, + speedAlongKnots: 6.5, + speedAthwartBowKnots: 0.7, + speedAthwartSternKnots: -0.7, + }, +}; + +export const AlongAthwartArrowsFramed: Story = { + args: { + frameStyle: SpeedDirectionsFrameStyle.framed, + speedAlongKnots: 4.2, + speedAthwartBowKnots: 0.8, + speedAthwartSternKnots: -0.6, + }, +}; + +export const LongLatArrowsFramed: Story = { + args: { + type: SpeedDirectionsType.longLatArrows, + frameStyle: SpeedDirectionsFrameStyle.framed, + speedAlongKnots: 4.2, + speedAthwartKnots: 0.8, + }, +}; + +export const AlongAthwartBarsFramed: Story = { + args: { + type: SpeedDirectionsType.alongAthwartBars, + frameStyle: SpeedDirectionsFrameStyle.framed, + speedAlongKnots: 6.5, + speedAthwartBowKnots: 0.7, + speedAthwartSternKnots: -0.7, + }, +}; + +export const AlongAthwartArrowsCompass: Story = { + args: { + frameStyle: SpeedDirectionsFrameStyle.compass, + speedAlongKnots: 4.2, + speedAthwartBowKnots: 0.8, + speedAthwartSternKnots: -0.6, + }, +}; + +export const LongLatArrowsCompass: Story = { + args: { + type: SpeedDirectionsType.longLatArrows, + frameStyle: SpeedDirectionsFrameStyle.compass, + speedAlongKnots: 4.2, + speedAthwartKnots: 0.8, + }, +}; + +export const AlongAthwartBarsCompass: Story = { + args: { + type: SpeedDirectionsType.alongAthwartBars, + frameStyle: SpeedDirectionsFrameStyle.compass, + speedAlongKnots: 6.5, + speedAthwartBowKnots: 0.7, + speedAthwartSternKnots: -0.7, + }, +}; + +export const ZeroSpeedTinted: Story = { + args: { + speedAlongKnots: 0, + speedAthwartBowKnots: 0, + speedAthwartSternKnots: 0, + tintedArrows: true, + }, +}; + +export const NegativeSpeeds: Story = { + args: { + type: SpeedDirectionsType.longLatArrows, + frameStyle: SpeedDirectionsFrameStyle.compass, + speedAlongKnots: -2.5, + speedAthwartKnots: -0.4, + }, +}; + +export const PartialAxes: Story = { + name: 'Partial Axes (Undefined Hidden)', + args: { + frameStyle: SpeedDirectionsFrameStyle.framed, + speedAlongKnots: 4.2, + }, +}; + +export const BarsMaxClamped: Story = { + args: { + type: SpeedDirectionsType.alongAthwartBars, + frameStyle: SpeedDirectionsFrameStyle.compass, + speedAlongKnots: 25, + speedAthwartBowKnots: 5, + speedAthwartSternKnots: -5, + }, +}; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions.ts b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions.ts new file mode 100644 index 000000000..85ceafa78 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/speed-directions/speed-directions.ts @@ -0,0 +1,303 @@ +import {LitElement, html, svg, nothing, unsafeCSS} from 'lit'; +import {property} from 'lit/decorators.js'; +import {ResizeController} from '@lit-labs/observers/resize-controller.js'; +import {customElement} from '../../decorator.js'; +import componentStyle from './speed-directions.css?inline'; +import { + VesselImage, + vesselImages, + WatchCircleType, + innerRingRadiusFor, +} from '../watch/watch.js'; +import {Tickmark, TickmarkType} from '../watch/tickmark.js'; +import {rect} from '../../svghelpers/rectangular.js'; +import { + computeRadialFrame, + measureContainerPx, + NORTH_ARROW_WIDTH_PX, +} from '../../svghelpers/radial-frame.js'; +import { + SpeedDirectionsType, + SpeedDirectionsFrameStyle, + SpeedAxis, + speedSteps, + barLengthUnits, + chevronCell, + ATHWART_AXIS_OFFSET, + VESSEL_CENTER_Y, + FLAT_VIEWBOX, + FRAME_HALF, + AXIS_LINE_HALF, + chordHalfLength, +} from './speed-directions-geometry.js'; +import { + renderSpeedChevrons, + renderSpeedBar, + renderAxisLine, +} from './speed-directions-art.js'; + +export {SpeedDirectionsType, SpeedDirectionsFrameStyle}; + +const COMPASS_TICKMARKS: Tickmark[] = [45, 135, 225, 315].map((angle) => ({ + angle, + type: TickmarkType.main, +})); + +/** + * Speed Directions - all-in-one directional speed instrument showing a vessel + * top view with speed indicators along the vessel axes. + * + * ## Features / Variants + * - Three indicator types: stepped chevron arrows on three axes + * (`alongAthwartArrows`), proportional bars with chevron tips + * (`alongAthwartBars`), and two-axis chevrons (`longLatArrows`). + * - Three frame styles: `standalone`, `framed` (rounded square with center + * cross lines), `compass` (watch face with main tickmarks and north arrow). + * - Value-driven: signed speeds in knots; an indicator renders only when its + * speed property is defined. + * + * ## Usage Guidelines + * Use for docking/maneuvering speed overviews. For a single stepped-arrow + * readout, use `obc-speed-arrows` instead. + * + * @experimental The API of this component is under design review and may + * change in a future release. + */ +@customElement('obc-speed-directions') +export class ObcSpeedDirections extends LitElement { + // @ts-expect-error TS6133: The controller ensures that the render + // function is called on resize of the element + private _resizeController = new ResizeController(this, {}); + + @property({type: String}) + type: SpeedDirectionsType = SpeedDirectionsType.alongAthwartArrows; + + @property({type: String}) + frameStyle: SpeedDirectionsFrameStyle = SpeedDirectionsFrameStyle.standalone; + + /** Signed speed along the vessel axis: positive fore, negative aft. */ + @property({type: Number}) + speedAlongKnots?: number = undefined; + + /** + * Signed athwart speed at the bow: positive starboard, negative port. + * @availableWhen type!=longLatArrows + */ + @property({type: Number}) + speedAthwartBowKnots?: number = undefined; + + /** + * Signed athwart speed at the stern: positive starboard, negative port. + * @availableWhen type!=longLatArrows + */ + @property({type: Number}) + speedAthwartSternKnots?: number = undefined; + + /** + * Signed athwart speed at midship: positive starboard, negative port. + * @availableWhen type==longLatArrows + */ + @property({type: Number}) + speedAthwartKnots?: number = undefined; + + /** Knots per chevron step on the along axis. */ + @property({type: Number}) + alongSpeedStepKnots = 3; + + /** Knots per chevron step on the athwart axes. */ + @property({type: Number}) + athwartSpeedStepKnots = 1; + + /** + * Along speed mapped to the full bar length. + * @availableWhen type==alongAthwartBars + */ + @property({type: Number}) + alongMaxSpeedKnots = 9; + + /** + * Athwart speed mapped to the full bar length. + * @availableWhen type==alongAthwartBars + */ + @property({type: Number}) + athwartMaxSpeedKnots = 3; + + /** + * Render inactive chevron bands as a tinted track. + * @availableWhen type!=alongAthwartBars + */ + @property({type: Boolean}) + tintedArrows = false; + + /** + * Vessel image for the framed and compass styles; the standalone style + * follows the design's fixed per-type vessel. + * @availableWhen frameStyle!=standalone + */ + @property({type: String}) + vesselImage: VesselImage = VesselImage.psvTop; + + private get isLongLat(): boolean { + return this.type === SpeedDirectionsType.longLatArrows; + } + + private axisSpeed(axis: SpeedAxis): number | undefined { + switch (axis) { + case 'along': + return this.speedAlongKnots; + case 'athwartBow': + return this.speedAthwartBowKnots; + case 'athwartStern': + return this.speedAthwartSternKnots; + case 'athwartMid': + return this.speedAthwartKnots; + } + } + + private get axes(): SpeedAxis[] { + return this.isLongLat + ? ['along', 'athwartMid'] + : ['along', 'athwartBow', 'athwartStern']; + } + + private stepKnots(axis: SpeedAxis): number { + return axis === 'along' + ? this.alongSpeedStepKnots + : this.athwartSpeedStepKnots; + } + + private renderChevronIndicators() { + return this.axes.map((axis) => { + const value = this.axisSpeed(axis); + if (value === undefined) return nothing; + const count = speedSteps(value, this.stepKnots(axis)); + const cell = chevronCell(this.type, this.frameStyle, axis, value >= 0); + return renderSpeedChevrons(cell, count, this.tintedArrows); + }); + } + + private renderVessel() { + const standalone = this.frameStyle === SpeedDirectionsFrameStyle.standalone; + const bars = this.type === SpeedDirectionsType.alongAthwartBars; + const image = standalone + ? bars + ? VesselImage.psvTop + : VesselImage.genericTop + : this.vesselImage; + const scale = standalone && !bars ? 2 : 1; + return svg`${vesselImages[image]}`; + } + + private renderContent() { + const bars = this.type === SpeedDirectionsType.alongAthwartBars; + if (bars) { + return svg`${this.renderBarIndicators()}${this.renderVessel()}`; + } + return svg`${this.renderVessel()}${this.renderChevronIndicators()}`; + } + + private renderBarIndicators() { + const maxFor = (axis: SpeedAxis) => + axis === 'along' ? this.alongMaxSpeedKnots : this.athwartMaxSpeedKnots; + return this.axes.map((axis) => { + const value = this.axisSpeed(axis); + if (value === undefined) return nothing; + const length = barLengthUnits(value, maxFor(axis)); + const tip = speedSteps(value, this.stepKnots(axis)); + const originY = + axis === 'athwartBow' + ? -ATHWART_AXIS_OFFSET + : axis === 'athwartStern' + ? ATHWART_AXIS_OFFSET + : 0; + const rotation = + axis === 'along' ? (value >= 0 ? 0 : 180) : value >= 0 ? 90 : 270; + return renderSpeedBar(0, originY, rotation, length, tip); + }); + } + + private renderFrame() { + if (this.frameStyle !== SpeedDirectionsFrameStyle.framed) return nothing; + return rect('speed-directions-frame', { + width: FRAME_HALF * 2, + height: FRAME_HALF * 2, + strokeWidth: 1, + strokeColor: 'var(--instrument-frame-tertiary-color)', + fillColor: 'var(--instrument-frame-primary-color)', + borderRadius: 8, + strokePosition: 'inside', + }); + } + + private renderAxisLines(halfAt: (offset: number) => number) { + if (this.frameStyle === SpeedDirectionsFrameStyle.standalone) { + return nothing; + } + const lines = [renderAxisLine('v', 0, halfAt(0))]; + if (this.isLongLat) { + lines.push(renderAxisLine('h', 0, halfAt(0))); + } else { + lines.push( + renderAxisLine('h', -ATHWART_AXIS_OFFSET, halfAt(-ATHWART_AXIS_OFFSET)) + ); + lines.push( + renderAxisLine('h', ATHWART_AXIS_OFFSET, halfAt(ATHWART_AXIS_OFFSET)) + ); + } + return lines; + } + + private renderCompass() { + const frame = computeRadialFrame({ + basePadding: 72, + labelWidthPx: NORTH_ARROW_WIDTH_PX, + containerPx: measureContainerPx(this), + }); + const discRadius = innerRingRadiusFor(WatchCircleType.single); + return html`
+ + + + ${this.renderAxisLines((offset) => chordHalfLength(discRadius, offset))} + ${this.renderContent()} + +
`; + } + + override render() { + if (this.frameStyle === SpeedDirectionsFrameStyle.compass) { + return this.renderCompass(); + } + return html`
+ + ${this.renderFrame()} ${this.renderAxisLines(() => AXIS_LINE_HALF)} + ${this.renderContent()} + +
`; + } + + static override styles = unsafeCSS(componentStyle); +} + +declare global { + interface HTMLElementTagNameMap { + 'obc-speed-directions': ObcSpeedDirections; + } +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/top-view-propulsion/top-view-propulsion.css b/packages/openbridge-webcomponents/src/navigation-instruments/top-view-propulsion/top-view-propulsion.css new file mode 100644 index 000000000..f565533e4 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/top-view-propulsion/top-view-propulsion.css @@ -0,0 +1,17 @@ +* { + box-sizing: border-box; +} + +.container { + position: relative; + width: 100%; + height: 100%; +} + +.container > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts b/packages/openbridge-webcomponents/src/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts new file mode 100644 index 000000000..7bdf9838a --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/top-view-propulsion/top-view-propulsion.stories.ts @@ -0,0 +1,146 @@ +import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import { + ObcTopViewPropulsion, + TopViewPropulsionType, +} from './top-view-propulsion.js'; +import './top-view-propulsion.js'; +import {InstrumentState, Priority} from '../types.js'; +import {widthDecorator} from '../../storybook-util.js'; +import {topPropellers} from '../watch/propellers/storybook-helper.js'; +import {TickmarkStyle} from '../watch/tickmark.js'; + +const meta: Meta = { + title: 'Instruments/Top View Propulsion', + tags: ['autodocs', '6.0', 'wip'], + component: 'obc-top-view-propulsion', + args: { + width: 384, + priority: Priority.enhanced, + }, + argTypes: { + width: {control: {type: 'range', min: 100, max: 1000, step: 1}}, + faceDiameter: {control: {type: 'range', min: 100, max: 600, step: 10}}, + power: {control: {type: 'range', min: -100, max: 100, step: 1}}, + powerSetpoint: {control: {type: 'range', min: -100, max: 100, step: 1}}, + loading: {control: {type: 'range', min: 0, max: 100, step: 1}}, + type: {control: 'select', options: Object.values(TopViewPropulsionType)}, + rpm: {control: {type: 'range', min: -100, max: 100, step: 1}}, + rpmSetpoint: {control: {type: 'range', min: -100, max: 100, step: 1}}, + pitch: {control: {type: 'range', min: -100, max: 100, step: 1}}, + state: {control: 'select', options: Object.values(InstrumentState)}, + priority: {control: 'select', options: Object.values(Priority)}, + propeller: {control: 'select', options: topPropellers}, + tickmarkStyle: {control: 'select', options: Object.values(TickmarkStyle)}, + }, + decorators: [widthDecorator], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const PowerAtSetpoint: Story = { + args: { + power: 62.5, + powerSetpoint: 62.5, + }, +}; + +export const PowerInput: Story = { + args: { + power: 62.5, + powerSetpoint: 62.5, + touching: true, + }, +}; + +export const PowerReady: Story = { + args: { + power: 0, + powerSetpoint: 0, + }, +}; + +export const PowerNotAtSetpoint: Story = { + args: { + power: 40, + powerSetpoint: 62.5, + }, +}; + +export const PowerRegularPriority: Story = { + args: { + power: 62.5, + powerSetpoint: 62.5, + priority: Priority.regular, + }, +}; + +export const PowerNegative: Story = { + args: { + power: -35, + powerSetpoint: -35, + }, +}; + +export const PowerLoading: Story = { + args: { + power: 0, + powerSetpoint: 0, + state: InstrumentState.loading, + loading: 65, + }, +}; + +export const PowerOff: Story = { + args: { + power: 0, + powerSetpoint: 0, + state: InstrumentState.off, + }, +}; + +export const Propellers: Story = { + args: { + power: 62.5, + powerSetpoint: 62.5, + propeller: topPropellers[5], + }, +}; + +export const PitchRpmAtSetpoint: Story = { + args: { + type: TopViewPropulsionType.pitchRpm, + rpm: 62.5, + rpmSetpoint: 62.5, + pitch: 85, + }, +}; + +export const PitchRpmRegularPriority: Story = { + args: { + type: TopViewPropulsionType.pitchRpm, + rpm: 62.5, + rpmSetpoint: 62.5, + pitch: 85, + priority: Priority.regular, + }, +}; + +export const PitchRpmNegative: Story = { + args: { + type: TopViewPropulsionType.pitchRpm, + rpm: -40, + rpmSetpoint: -40, + pitch: -60, + }, +}; + +export const PitchRpmOff: Story = { + args: { + type: TopViewPropulsionType.pitchRpm, + rpm: 0, + rpmSetpoint: 0, + pitch: 0, + state: InstrumentState.off, + }, +}; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/top-view-propulsion/top-view-propulsion.ts b/packages/openbridge-webcomponents/src/navigation-instruments/top-view-propulsion/top-view-propulsion.ts new file mode 100644 index 000000000..2a8fc56fe --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/top-view-propulsion/top-view-propulsion.ts @@ -0,0 +1,521 @@ +import {html, svg, LitElement, nothing, unsafeCSS} from 'lit'; +import {property} from 'lit/decorators.js'; +import type {PropertyValues} from 'lit'; +import {ResizeController} from '@lit-labs/observers/resize-controller.js'; +import { + applyPinnedHostSize, + computeRadialFrame, + estimateLabelWidthPx, + measureContainerPx, + observeInnerBox, + type RadialFrame, +} from '../../svghelpers/radial-frame.js'; +import {InstrumentState, Priority} from '../types.js'; +import {SetpointBundle} from '../../svghelpers/setpoint-bundle.js'; +import '../watch/watch.js'; +import { + OUTER_RING_RADIUS, + WatchCircleType, + innerRingRadiusFor, + type WatchBarArea, + type WatchNeedle, +} from '../watch/watch.js'; +import {Tickmark, TickmarkStyle, TickmarkType} from '../watch/tickmark.js'; +import {PropellerImage, propellerImages} from '../watch/propeller.js'; +import {customElement} from '../../decorator.js'; +import componentStyle from './top-view-propulsion.css?inline'; + +export enum TopViewPropulsionType { + power = 'power', + pitchRpm = 'pitch-rpm', +} + +const FULL_CIRCLE_END_ANGLE = 359.999; +const LOADING_ARC_RADIUS = (OUTER_RING_RADIUS + 320 / 2) / 2; +const PROPELLER_SCALE = 224 / 160; +/* pitch-rpm subdivision of the 112..160 band (Figma: divider outer edge at + 264 diameter): secondary pitch lane 112..120, white divider 120..128, + primary sub-band 128..160. */ +const BAND_INNER_RADIUS = innerRingRadiusFor(WatchCircleType.double); +const BAND_OUTER_RADIUS = innerRingRadiusFor(WatchCircleType.single); +const SECONDARY_LINE_WIDTH = 8; +const DIVIDER_WIDTH = 8; +const SECONDARY_LANE_RADIUS = BAND_INNER_RADIUS + SECONDARY_LINE_WIDTH / 2; +const DIVIDER_RADIUS = + BAND_INNER_RADIUS + SECONDARY_LINE_WIDTH + DIVIDER_WIDTH / 2; +const PRIMARY_SUBBAND_INNER_RADIUS = + BAND_INNER_RADIUS + SECONDARY_LINE_WIDTH + DIVIDER_WIDTH; +const RPM_NEEDLE_LENGTH = BAND_OUTER_RADIUS - PRIMARY_SUBBAND_INNER_RADIUS; + +function percentToAngle(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(-100, Math.min(100, value)) * 1.8; +} + +function arcPath(radius: number, startDeg: number, endDeg: number): string { + const toRad = (deg: number) => ((deg - 90) * Math.PI) / 180; + const x1 = radius * Math.cos(toRad(startDeg)); + const y1 = radius * Math.sin(toRad(startDeg)); + const x2 = radius * Math.cos(toRad(endDeg)); + const y2 = radius * Math.sin(toRad(endDeg)); + const largeArc = Math.abs(endDeg - startDeg) > 180 ? 1 : 0; + const sweep = endDeg > startDeg ? 1 : 0; + return `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} ${sweep} ${x2} ${y2}`; +} + +/** + * Top View Propulsion - a propulsion gauge on a circular watch face with a + * top-view propeller in the center. + * + * ## Features / Variants + * - `power` type: signed percent value shown as a tinted arc on the inner + * track from the thick zero line, with a needle at the arc end. + * - `pitch-rpm` type: rpm on the primary track (same rendering as power) + * plus pitch as a thin secondary arc at the track's inner edge with its + * own zero mark and end nub. + * - Interchangeable center propeller art (`PropellerImage`). + * - Full setpoint support (marker, adjustment preview, at-setpoint states) + * for the primary axis (power or rpm). + * - Loading progress arc on the outer ring while `state` is `loading`. + * + * ## Usage Guidelines + * Use for a propulsion unit overview. For an azimuthing unit with a + * direction bar, use `obc-azimuth-thruster` instead. + * + * @experimental The API of this component is under design review and may + * change in a future release. + */ +@customElement('obc-top-view-propulsion') +export class ObcTopViewPropulsion extends LitElement { + @property({type: String}) type: TopViewPropulsionType = + TopViewPropulsionType.power; + + /** + * Signed power in percent: 0 at the top, positive clockwise, ±100% = ±180°. + * @availableWhen type==power + */ + @property({type: Number}) power = 0; + /** @availableWhen type==power */ + @property({type: Number}) powerSetpoint: number | undefined; + /** @availableWhen type==power && powerSetpoint!=undefined */ + @property({type: Number}) newPowerSetpoint: number | undefined; + /** @availableWhen type==power && powerSetpoint!=undefined && autoAtPowerSetpoint==false */ + @property({type: Boolean}) atPowerSetpoint = false; + /** @availableWhen type==power && powerSetpoint!=undefined */ + @property({type: Number}) powerSetpointAtZeroDeadband = 0.1; + /** @availableWhen type==power && powerSetpoint!=undefined */ + @property({type: Boolean}) powerSetpointOverride = false; + /** @availableWhen type==power && powerSetpoint!=undefined */ + @property({type: Boolean, attribute: false}) autoAtPowerSetpoint = true; + /** @availableWhen type==power && powerSetpoint!=undefined && autoAtPowerSetpoint==true */ + @property({type: Number}) autoAtPowerSetpointDeadband = 1; + + /** + * Signed rpm in percent of the maximum: 0 at the top, positive clockwise, + * ±100% = ±180°. Shown on the primary track. + * @availableWhen type==pitch-rpm + */ + @property({type: Number}) rpm = 0; + /** @availableWhen type==pitch-rpm */ + @property({type: Number}) rpmSetpoint: number | undefined; + /** @availableWhen type==pitch-rpm && rpmSetpoint!=undefined */ + @property({type: Number}) newRpmSetpoint: number | undefined; + /** @availableWhen type==pitch-rpm && rpmSetpoint!=undefined && autoAtRpmSetpoint==false */ + @property({type: Boolean}) atRpmSetpoint = false; + /** @availableWhen type==pitch-rpm && rpmSetpoint!=undefined */ + @property({type: Number}) rpmSetpointAtZeroDeadband = 0.1; + /** @availableWhen type==pitch-rpm && rpmSetpoint!=undefined */ + @property({type: Boolean}) rpmSetpointOverride = false; + /** @availableWhen type==pitch-rpm && rpmSetpoint!=undefined */ + @property({type: Boolean, attribute: false}) autoAtRpmSetpoint = true; + /** @availableWhen type==pitch-rpm && rpmSetpoint!=undefined && autoAtRpmSetpoint==true */ + @property({type: Number}) autoAtRpmSetpointDeadband = 1; + + /** + * Signed pitch in percent: 0 at the top, positive clockwise, ±100% = ±180°. + * Shown as the thin secondary arc. + * @availableWhen type==pitch-rpm + */ + @property({type: Number}) pitch = 0; + + /** @availableWhen powerSetpoint!=undefined || rpmSetpoint!=undefined */ + @property({type: Boolean}) touching = false; + /** @availableWhen powerSetpoint!=undefined || rpmSetpoint!=undefined */ + @property({type: Boolean}) animateSetpoint = false; + + @property({type: String}) state: InstrumentState = InstrumentState.active; + @property({type: String}) priority: Priority = Priority.regular; + /** + * Loading progress in percent, shown as an arc on the outer ring. + * @availableWhen state==loading + */ + @property({type: Number}) loading = 0; + + /** + * Interval (in degrees) for primary tickmarks. + * When undefined or <= 0, no primary tickmarks are shown (only the zero line). + */ + @property({type: Number}) primaryTickmarkInterval: number | undefined = 90; + /** + * Interval (in degrees) for secondary tickmarks. + * When undefined or <= 0, no secondary tickmarks are shown. + */ + @property({type: Number}) secondaryTickmarkInterval: number | undefined = + undefined; + /** + * Interval (in degrees) for tertiary tickmarks. + * When undefined or <= 0, no tertiary tickmarks are shown. + */ + @property({type: Number}) tertiaryTickmarkInterval: number | undefined = + undefined; + @property({type: Boolean}) showLabels = false; + @property({type: Boolean}) tickmarksInside = false; + @property({type: String}) tickmarkStyle: TickmarkStyle = + TickmarkStyle.regular; + @property({type: Boolean, attribute: false}) hasLabelSpacer = true; + + @property({type: String}) propeller: PropellerImage = + PropellerImage.fourBlade; + + /** + * Outer-ring diameter in CSS pixels. When set, the instrument renders at a + * fixed intrinsic size derived from the ring, arc shape and label reserve — + * so instruments sharing the same value have identical ring circumference + * regardless of label width or arc extent (like obc-donut-chart's + * fixedHeight). When unset (default), the instrument fills its container. + */ + @property({type: Number, attribute: 'face-diameter'}) + faceDiameter: number | undefined; + + private _powerSp = new SetpointBundle({ + defaultDeadband: 1, + defaultZeroDeadband: 0.1, + onAnimationEnd: () => this.requestUpdate(), + }); + private _rpmSp = new SetpointBundle({ + defaultDeadband: 1, + defaultZeroDeadband: 0.1, + onAnimationEnd: () => this.requestUpdate(), + }); + + private _frame: RadialFrame | undefined; + private _hostSizePinned = false; + private _resizeController = new ResizeController(this, {}); + + override firstUpdated(changed: PropertyValues): void { + super.firstUpdated(changed); + observeInnerBox(this._resizeController, this.renderRoot); + } + + override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + this._powerSp.sync({ + setpoint: this.powerSetpoint, + newSetpoint: this.newPowerSetpoint, + atSetpoint: this.atPowerSetpoint, + touching: this.touching, + autoAtSetpoint: this.autoAtPowerSetpoint, + autoAtSetpointDeadband: this.autoAtPowerSetpointDeadband, + setpointAtZeroDeadband: this.powerSetpointAtZeroDeadband, + setpointOverride: this.powerSetpointOverride, + animateSetpoint: this.animateSetpoint, + }); + this._rpmSp.sync({ + setpoint: this.rpmSetpoint, + newSetpoint: this.newRpmSetpoint, + atSetpoint: this.atRpmSetpoint, + touching: this.touching, + autoAtSetpoint: this.autoAtRpmSetpoint, + autoAtSetpointDeadband: this.autoAtRpmSetpointDeadband, + setpointAtZeroDeadband: this.rpmSetpointAtZeroDeadband, + setpointOverride: this.rpmSetpointOverride, + animateSetpoint: this.animateSetpoint, + }); + } + + private get isPitchRpm(): boolean { + return this.type === TopViewPropulsionType.pitchRpm; + } + + private get primaryValue(): number { + return this.isPitchRpm ? this.rpm : this.power; + } + + private get primarySp(): SetpointBundle { + return this.isPitchRpm ? this._rpmSp : this._powerSp; + } + + private get primarySetpoint(): number | undefined { + return this.isPitchRpm ? this.rpmSetpoint : this.powerSetpoint; + } + + private get primaryNewSetpoint(): number | undefined { + return this.isPitchRpm ? this.newRpmSetpoint : this.newPowerSetpoint; + } + + private get primaryZeroDeadband(): number { + return this.isPitchRpm + ? this.rpmSetpointAtZeroDeadband + : this.powerSetpointAtZeroDeadband; + } + + private get primarySetpointOverride(): boolean { + return this.isPitchRpm + ? this.rpmSetpointOverride + : this.powerSetpointOverride; + } + + override updated(changed: PropertyValues): void { + super.updated(changed); + this._hostSizePinned = applyPinnedHostSize( + this, + this._frame, + this._hostSizePinned + ); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this._powerSp.dispose(); + this._rpmSp.dispose(); + } + + private get isActive(): boolean { + return ( + this.state !== InstrumentState.loading && + this.state !== InstrumentState.off + ); + } + + private get isEnhanced(): boolean { + return this.priority === Priority.enhanced; + } + + private getTickmarks(): Tickmark[] { + const tickmarks: Tickmark[] = []; + const skipAngles: number[] = []; + + const labelText = (angle: number): string | undefined => { + if (!this.showLabels) return undefined; + return angle <= 180 ? `${angle}` : `${angle - 360}`; + }; + + tickmarks.push({ + angle: 0, + type: TickmarkType.zeroLineThick, + text: this.showLabels ? '0' : undefined, + color: + this.isActive && this.isEnhanced + ? 'var(--instrument-enhanced-tertiary-color)' + : undefined, + }); + skipAngles.push(0); + + const addInterval = ( + interval: number | undefined, + type: TickmarkType, + withLabels = false + ) => { + if (interval === undefined || interval <= 0 || !Number.isFinite(interval)) + return; + for (let i = interval; i < 360; i += interval) { + if (skipAngles.includes(i)) continue; + tickmarks.push({ + angle: i, + type, + text: withLabels ? labelText(i) : undefined, + }); + skipAngles.push(i); + } + }; + + addInterval(this.primaryTickmarkInterval, TickmarkType.primary, true); + addInterval(this.secondaryTickmarkInterval, TickmarkType.secondary); + addInterval(this.tertiaryTickmarkInterval, TickmarkType.tertiary); + + return tickmarks; + } + + private getBarAreas(): WatchBarArea[] { + const areas: WatchBarArea[] = [ + { + startAngle: 0, + endAngle: FULL_CIRCLE_END_ANGLE, + fillColor: 'var(--instrument-frame-secondary-color)', + }, + ]; + const valueAngle = percentToAngle(this.primaryValue); + if (this.isActive && Math.abs(valueAngle) > 0.1) { + areas.push({ + startAngle: Math.min(0, valueAngle), + endAngle: Math.max(0, valueAngle), + fillColor: this.isEnhanced + ? 'var(--instrument-enhanced-tertiary-color)' + : 'var(--instrument-regular-tertiary-color)', + innerRadius: this.isPitchRpm ? PRIMARY_SUBBAND_INNER_RADIUS : undefined, + }); + } + return areas; + } + + private getNeedles(): WatchNeedle[] { + const fill = !this.isActive + ? 'var(--instrument-frame-tertiary-color)' + : this.isEnhanced + ? 'var(--instrument-enhanced-secondary-color)' + : 'var(--instrument-regular-secondary-color)'; + const stroke = !this.isActive + ? 'var(--border-silhouette-color)' + : this.isEnhanced + ? 'var(--instrument-enhanced-tertiary-color)' + : 'var(--instrument-regular-tertiary-color)'; + return [ + { + angle: percentToAngle(this.primaryValue), + fillColor: fill, + strokeColor: stroke, + length: this.isPitchRpm ? RPM_NEEDLE_LENGTH : undefined, + }, + ]; + } + + private get secondaryColor(): string { + if (!this.isActive) { + return 'var(--instrument-frame-tertiary-color)'; + } + return this.isEnhanced + ? 'var(--instrument-enhanced-secondary-color)' + : 'var(--instrument-regular-secondary-color)'; + } + + private renderSecondaryArc() { + if (!this.isPitchRpm) { + return nothing; + } + const r = SECONDARY_LANE_RADIUS; + const divider = svg``; + const pitchAngle = percentToAngle(this.pitch); + if (!this.isActive || Math.abs(pitchAngle) < 0.5) { + return svg` + ${divider} + + `; + } + const toRad = ((pitchAngle - 90) * Math.PI) / 180; + const endX = r * Math.cos(toRad); + const endY = r * Math.sin(toRad); + return svg` + ${divider} + + + `; + } + + private renderLoadingArc() { + if (this.state !== InstrumentState.loading || this.loading <= 0) { + return nothing; + } + const endAngle = percentToAngle(Math.abs(this.loading)) * 2; + return svg``; + } + + private renderPropeller() { + return svg`${ + propellerImages[this.propeller] + }`; + } + + override render() { + const tickmarks = this.getTickmarks(); + + const frame = computeRadialFrame({ + basePadding: this.hasLabelSpacer ? 24 : 16, + labelWidthPx: + this.hasLabelSpacer && !this.tickmarksInside + ? estimateLabelWidthPx(tickmarks.map((t) => t.text)) + : 0, + containerPx: measureContainerPx(this), + faceDiameter: this.faceDiameter, + }); + this._frame = frame; + const shownTickmarks = frame.labelsHidden + ? tickmarks.map((t) => ({...t, text: undefined})) + : tickmarks; + + const setpointAngle = + this.primarySetpoint !== undefined + ? percentToAngle(this.primarySetpoint) + : undefined; + const newSetpointAngle = + this.primaryNewSetpoint !== undefined + ? percentToAngle(this.primaryNewSetpoint) + : undefined; + + return html` +
+ + + ${this.renderLoadingArc()} ${this.renderSecondaryArc()} + ${this.renderPropeller()} + +
+ `; + } + + static override styles = unsafeCSS(componentStyle); +} + +declare global { + interface HTMLElementTagNameMap { + 'obc-top-view-propulsion': ObcTopViewPropulsion; + } +} diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch-flat/watch-flat.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch-flat/watch-flat.ts index 6263168e0..d5375412b 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/watch-flat/watch-flat.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch-flat/watch-flat.ts @@ -32,6 +32,20 @@ import { export {RotType, LinearRotPosition}; +/** Filled segment of the track band, in SVG user-space x-coordinates (center-origin). */ +export interface WatchFlatBarArea { + startX: number; + endX: number; + fillColor: string; +} + +/** Needle marker spanning the track band at `x` (center-origin). */ +export interface WatchFlatNeedle { + x: number; + fillColor: string; + strokeColor: string; +} + /** * @experimental */ @@ -51,6 +65,10 @@ export class ObcWatchFlat extends LitElement { @property({type: Number}) ticksHeight = this.height - this.trackHeight; @property({type: Number}) borderRadius = 8; @property({type: Boolean}) bottomBar = false; + /** Filled segments rendered inside the track band (the linear counterpart of the radial bar areas). */ + @property({type: Array, attribute: false}) barAreas: WatchFlatBarArea[] = []; + /** Needle markers spanning the track band (the linear counterpart of the radial needles). */ + @property({type: Array, attribute: false}) needles: WatchFlatNeedle[] = []; @property({type: String}) rotType: RotType | undefined; /** @availableWhen rotType!=undefined */ @@ -219,6 +237,54 @@ export class ObcWatchFlat extends LitElement { `; } + /** Top edge of the track band in SVG user space. */ + private get trackBandY(): number { + return ( + this.totalHeight / 2 - + this.trackHeight - + (this.bottomBar ? this.ticksHeight : 0) + ); + } + + private renderBandBars(): SVGTemplateResult[] | typeof nothing { + if (this.barAreas.length === 0) { + return nothing; + } + return this.barAreas.map( + (bar) => svg` + + ` + ); + } + + private renderBandNeedles(): SVGTemplateResult[] | typeof nothing { + if (this.needles.length === 0) { + return nothing; + } + return this.needles.map( + (needle) => svg` + + ` + ); + } + // --------------------------------------------------------------------------- // ROT (Rate of Turn) — linear // --------------------------------------------------------------------------- @@ -385,6 +451,9 @@ export class ObcWatchFlat extends LitElement { style="--scale: ${scale}" > ${this.watchFace()} ${this.renderLabelMask()} + + ${this.renderBandBars()} ${this.renderBandNeedles()} + diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/propeller.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propeller.ts new file mode 100644 index 000000000..8fd743d8c --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propeller.ts @@ -0,0 +1,39 @@ +import {SVGTemplateResult} from 'lit'; + +import threeBlade from './propellers/three-blade.js'; +import fourBlade from './propellers/four-blade.js'; +import fiveBlade from './propellers/five-blade.js'; +import sixBlade from './propellers/six-blade.js'; +import shaftlessFiveBlade from './propellers/shaftless-five-blade.js'; +import shaftlessSevenBlade from './propellers/shaftless-seven-blade.js'; +import vspSevenBlade from './propellers/vsp-seven-blade.js'; +import capFiveBlade from './propellers/cap-five-blade.js'; + +export enum PropellerImage { + threeBlade = 'three-blade', + fourBlade = 'four-blade', + fiveBlade = 'five-blade', + sixBlade = 'six-blade', + shaftlessFiveBlade = 'shaftless-five-blade', + shaftlessSevenBlade = 'shaftless-seven-blade', + vspSevenBlade = 'vsp-seven-blade', + // TODO(designer): the Figma variant is named "Type9"; the art reads as a + // bladed hub cap, named descriptively here until the design names it. + capFiveBlade = 'cap-five-blade', +} + +/** + * Top-view propeller art in the shared 160x160 vessel-image coordinate + * system, centered at (80, 80). Consumers center it with the same + * `scale(size / 160) translate(-80 -80)` wrapper as `vesselImages`. + */ +export const propellerImages: Record = { + [PropellerImage.threeBlade]: threeBlade, + [PropellerImage.fourBlade]: fourBlade, + [PropellerImage.fiveBlade]: fiveBlade, + [PropellerImage.sixBlade]: sixBlade, + [PropellerImage.shaftlessFiveBlade]: shaftlessFiveBlade, + [PropellerImage.shaftlessSevenBlade]: shaftlessSevenBlade, + [PropellerImage.vspSevenBlade]: vspSevenBlade, + [PropellerImage.capFiveBlade]: capFiveBlade, +}; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/cap-five-blade.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/cap-five-blade.ts new file mode 100644 index 000000000..8da43264d --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/cap-five-blade.ts @@ -0,0 +1,20 @@ +import {svg} from 'lit'; + +export default svg` + + + + + + + + + + + + + + + + +`; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/five-blade.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/five-blade.ts new file mode 100644 index 000000000..43f57901b --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/five-blade.ts @@ -0,0 +1,22 @@ +import {svg} from 'lit'; + +export default svg` + + + + + + + + + + + + + + + + + + +`; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/four-blade.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/four-blade.ts new file mode 100644 index 000000000..2d2deb292 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/four-blade.ts @@ -0,0 +1,20 @@ +import {svg} from 'lit'; + +export default svg` + + + + + + + + + + + + + + + + +`; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/shaftless-five-blade.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/shaftless-five-blade.ts new file mode 100644 index 000000000..1d45a9e4b --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/shaftless-five-blade.ts @@ -0,0 +1,24 @@ +import {svg} from 'lit'; + +export default svg` + + + + + + + + + + + + + + + + + + + + +`; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/shaftless-seven-blade.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/shaftless-seven-blade.ts new file mode 100644 index 000000000..ff8ce5b45 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/shaftless-seven-blade.ts @@ -0,0 +1,27 @@ +import {svg} from 'lit'; + +export default svg` + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/six-blade.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/six-blade.ts new file mode 100644 index 000000000..cb3ee9310 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/six-blade.ts @@ -0,0 +1,26 @@ +import {svg} from 'lit'; + +export default svg` + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/storybook-helper.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/storybook-helper.ts new file mode 100644 index 000000000..d00228a80 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/storybook-helper.ts @@ -0,0 +1,3 @@ +import {PropellerImage} from '../propeller.js'; + +export const topPropellers = Object.values(PropellerImage); diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/three-blade.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/three-blade.ts new file mode 100644 index 000000000..ba199e206 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/three-blade.ts @@ -0,0 +1,18 @@ +import {svg} from 'lit'; + +export default svg` + + + + + + + + + + + + + + +`; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/vsp-seven-blade.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/vsp-seven-blade.ts new file mode 100644 index 000000000..7735a1190 --- /dev/null +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/propellers/vsp-seven-blade.ts @@ -0,0 +1,24 @@ +import {svg} from 'lit'; + +export default svg` + + + + + + + + + + + + + + + + + + + + +`; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/tickmark.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/tickmark.ts index 8ef12d573..5cdfda743 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/watch/tickmark.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/tickmark.ts @@ -22,6 +22,45 @@ export enum TickmarkStyle { enhanced = 'enhanced', } +/** + * Secondary tickmark ladder spanning a watch arc. + * + * Emits a `secondary` mark every `interval` degrees strictly inside the arc, + * skipping the arc centre — which carries its own `main` mark — and the arc + * ends, where the band's rounded end cap already reads as a boundary. Angles + * are returned signed relative to `centerAngle`, matching the convention the + * inclinometers already use for needles and bar areas. + * + * @param centerAngle Watch angle the arc is centred on. + * @param halfExtent Half-extent of the arc in degrees; the arc spans `centerAngle ± halfExtent`. + * @param interval Spacing between ladder marks in degrees. + */ +export function arcTickmarks( + centerAngle: number, + halfExtent: number, + interval = 5 +): Tickmark[] { + if ( + !Number.isFinite(centerAngle) || + !Number.isFinite(halfExtent) || + !Number.isFinite(interval) || + interval <= 0 + ) { + return []; + } + const marks: Tickmark[] = []; + const epsilon = 1e-6; + for ( + let offset = interval; + offset < halfExtent - epsilon; + offset += interval + ) { + marks.push({angle: centerAngle - offset, type: TickmarkType.secondary}); + marks.push({angle: centerAngle + offset, type: TickmarkType.secondary}); + } + return marks; +} + export function tickmarkColor( style: TickmarkStyle, tickmarkType?: TickmarkType diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/vessels/generic-top.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/vessels/generic-top.ts index e78b5cb5f..2ddc0e716 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/watch/vessels/generic-top.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/vessels/generic-top.ts @@ -1,10 +1,12 @@ import {svg} from 'lit'; +// TODO(designer): the source of truth is Figma node 208-29982 ("Vessel", +// Generic/Top); its export draws the outline as a mask-based inside stroke +// that scales with the vessel (the bug this hand-conversion fixes). When +// regenerating via script/convert-vessel-svg-to-ts.ts, keep the outline as a +// real non-scaling stroke like the other vessel assets. export default svg` - - - - + `; diff --git a/packages/openbridge-webcomponents/src/navigation-instruments/watch/watch.ts b/packages/openbridge-webcomponents/src/navigation-instruments/watch/watch.ts index e3d1527aa..76ea272fe 100644 --- a/packages/openbridge-webcomponents/src/navigation-instruments/watch/watch.ts +++ b/packages/openbridge-webcomponents/src/navigation-instruments/watch/watch.ts @@ -82,12 +82,18 @@ export interface WatchBarArea { startAngle: number; endAngle: number; fillColor: string; + /** Outer band radius override; defaults to the second ring (160). */ + outerRadius?: number; + /** Inner band radius override; defaults to the third ring (112). */ + innerRadius?: number; } export interface WatchNeedle { angle: number; fillColor: string; strokeColor: string; + /** Radial length of the needle pill; defaults to 48 (the full band). */ + length?: number; } export interface WatchVessel { @@ -232,6 +238,12 @@ export class ObcWatch extends LitElement { TickmarkStyle.regular; @property({type: Array, attribute: false}) advices: AngleAdviceRaw[] = []; @property({type: Boolean}) crosshairEnabled: boolean = false; + /** + * Cuts the crosshair out inside the inner ring, so center content (e.g. + * center readouts) sits on a clean face. + * @availableWhen crosshairEnabled==true + */ + @property({type: Boolean}) crosshairCenterCutout: boolean = false; @property({type: Boolean}) showLabels: boolean = false; @property({type: Array, attribute: false}) vessels: WatchVessel[] = []; @property({type: Number}) windKnots: number | null = null; @@ -582,13 +594,16 @@ export class ObcWatch extends LitElement { scale: number; /** Inner ring radius – crosshair is hidden between labelRadius and this value. */ innerRingRadius: number; - } + }, + centerCutoutRadius?: number ): SVGTemplateResult { - const hasMask = labelKnockouts && labelKnockouts.positions.length > 0; + const hasLabelKnockouts = + !!labelKnockouts && labelKnockouts.positions.length > 0; + const hasMask = hasLabelKnockouts || centerCutoutRadius !== undefined; // Radius at which labels sit (distance from centre). // Any position is equally valid — they're all at the same radial distance. - const labelRadius = hasMask + const labelRadius = hasLabelKnockouts ? Math.max( ...labelKnockouts!.positions.map((l) => Math.abs(l.x !== 0 ? l.x : l.y) @@ -597,7 +612,7 @@ export class ObcWatch extends LitElement { : 0; // Small extra padding so the crosshair doesn't start/end right at the // label edge — use the same visual pad as the letter knockouts. - const ringGapPad = hasMask ? 3 / labelKnockouts!.scale : 0; + const ringGapPad = hasLabelKnockouts ? 3 / labelKnockouts!.scale : 0; return svg` ${ @@ -611,6 +626,9 @@ export class ObcWatch extends LitElement { width="${radius * 2}" height="${radius * 2}" > + ${ + hasLabelKnockouts + ? svg` @@ -628,7 +646,14 @@ export class ObcWatch extends LitElement { transform-origin="${l.x} ${l.y}" /> `; - })} + })}` + : nothing + } + ${ + centerCutoutRadius !== undefined + ? svg`` + : nothing + } ` : nothing @@ -664,14 +689,14 @@ export class ObcWatch extends LitElement { const startAngle = Math.min(bar.startAngle, bar.endAngle); const endAngle = Math.max(bar.startAngle, bar.endAngle); const arc = roundedArch({ - r: this._bandRadius(RING3_RADIUS), - R: this._bandRadius(RING2_RADIUS), + r: this._bandRadius(bar.innerRadius ?? RING3_RADIUS), + R: this._bandRadius(bar.outerRadius ?? RING2_RADIUS), startAngle: startAngle, endAngle: endAngle, roundInsideCut: false, roundOutsideCut: false, }); - const barMaskR = RING2_RADIUS + this._rOff + 40; + const barMaskR = (bar.outerRadius ?? RING2_RADIUS) + this._rOff + 40; // The mask is a sector to cut out the stroke on the start and end of the bar const mask = svg` @@ -686,14 +711,14 @@ export class ObcWatch extends LitElement { `; return svg` ${mask} - - 0 ? 'url(#cutMask)' : nothing}> + `; @@ -706,10 +731,10 @@ export class ObcWatch extends LitElement { } return this.needles.map((needle) => { return svg` -