;
+
+export const Speedometer: Story = {
+ args: {
+ width: 512,
+ maxSpeed: 27,
+ mainSpeedType: SpeedType.SOG,
+ sogSpeed: 22.7,
+ stwSpeed: 23.7,
+ alertLow: -2,
+ alertHigh: 24,
+ alertLowType: AlertTypes.none,
+ alertHighType: AlertTypes.caution,
+ showSog: true,
+ showStw: true,
+ useAlerts: true,
+ showSpeedAsNumber: false
+ },
+};
+
+export const SpeedometerSogOnly: Story = {
+ args: {
+ width: 512,
+ maxSpeed: 27,
+ mainSpeedType: SpeedType.SOG,
+ sogSpeed: 5.49,
+ alertLow: -2,
+ alertHigh: 22,
+ alertLowType: AlertTypes.alarm,
+ alertHighType: AlertTypes.caution,
+ showSog: true,
+ showStw: false,
+ useAlerts: true
+ },
+};
+
+export const SpeedometerStwOnly: Story = {
+ args: {
+ width: 512,
+ maxSpeed: 27,
+ mainSpeedType: SpeedType.STW,
+ stwSpeed: 5.49,
+ alertLow: -2,
+ alertHigh: 22,
+ alertLowType: AlertTypes.alarm,
+ alertHighType: AlertTypes.caution,
+ showSog: false,
+ showStw: true,
+ useAlerts: true
+ },
+};
\ No newline at end of file
diff --git a/packages/openbridge-webcomponents/src/skipper/speedometer/speedometer.ts b/packages/openbridge-webcomponents/src/skipper/speedometer/speedometer.ts
new file mode 100644
index 000000000..0dd12cf4d
--- /dev/null
+++ b/packages/openbridge-webcomponents/src/skipper/speedometer/speedometer.ts
@@ -0,0 +1,1021 @@
+import {LitElement, svg, html, SVGTemplateResult, nothing} from 'lit';
+import {property} from 'lit/decorators.js';
+import {customElement} from '../../decorator.js';
+import { speedometerStyles } from './speedometer-styles.js';
+import { roundedArch } from '../../svghelpers/roundedArch.js';
+import { AdviceState, AdviceType, renderAdvice } from '../advice.js';
+import { InstrumentFieldSize } from '../../navigation-instruments/instrument-field/instrument-field.js';
+import { AlertColor, AlertTypes, Colors, InstrumentField, SpeedType, WatchBarArea } from '../interfaces.js';
+
+export const OUTER_RING_RADIUS = 368 / 2;
+const RING2_RADIUS = 528.25 / 2;
+const RING3_RADIUS = 373.25 / 2;
+
+@customElement('ob-speedometer')
+export class Speedometer extends LitElement {
+ @property({type: Number}) maxSpeed = 25;
+ @property({type: String}) mainSpeedType = SpeedType.SOG;
+ @property({type: Number}) sogSpeed = 5;
+ @property({type: Number}) fractionDigits = 2;
+ @property({type: Number}) stwSpeed = 5;
+ @property({type: Boolean}) useAlerts = false;
+ @property({type: Number}) alertLow = 5;
+ @property({type: String}) alertLowType: AlertTypes = AlertTypes.alarm;
+ @property({type: Number}) alertHigh = 22;
+ @property({type: String}) alertHighType: AlertTypes = AlertTypes.caution;
+ @property({type: Boolean}) showSog = false;
+ @property({type: Boolean}) showStw = false;
+ @property({type: Boolean}) showSpeedAsNumber = false;
+
+ private rotateStw = 0;
+
+ private rotate = 0;
+ private rotationAngle = 9;
+
+ private minSpeed = -5;
+ private lowIntegrity = false;
+
+ private viewBox = "130 80 712 712";
+
+ override render() {
+ this.setViewBox();
+ this.setMaxSpeed();
+ this.setRotate();
+ this.setStwRotate();
+
+ return html`
+
+ ${this.getSpeedometer()}
+
+ `;
+ }
+
+ setViewBox() {
+ if (this.showSog && this.showStw) {
+ this.viewBox = "130 80 712 712";
+ }
+ else {
+ this.viewBox = "130 80 712 612";
+ }
+ }
+
+ static override styles = [
+ speedometerStyles
+ ];
+
+ getSogSpeed(): number {
+ if (this.sogSpeed <= this.maxSpeed && this.sogSpeed >= this.minSpeed) {
+ return this.sogSpeed;
+ }
+ else if (this.sogSpeed > this.maxSpeed) {
+ return this.maxSpeed;
+ }
+ else if (this.sogSpeed < this.minSpeed) {
+ return this.minSpeed;
+ }
+ else {
+ return this.minSpeed;
+ }
+ }
+
+ getStwSpeed(): number {
+ if (this.stwSpeed <= this.maxSpeed && this.stwSpeed >= this.minSpeed) {
+ return this.stwSpeed;
+ }
+ else if (this.stwSpeed > this.maxSpeed) {
+ return this.maxSpeed;
+ }
+ else if (this.stwSpeed < this.minSpeed) {
+ return this.minSpeed;
+ }
+ else {
+ return this.minSpeed;
+ }
+ }
+
+ private getAngle(v: number): number {
+ return (v / this.maxSpeed) * (180 + 45) - 90;
+ }
+
+ private setMaxSpeed(): void {
+ if (this.maxSpeed <= 25) {
+ this.maxSpeed = 25;
+ this.minSpeed = -5;
+ this.rotationAngle = 9;
+ }
+ else if (this.maxSpeed > 25) {
+ this.maxSpeed = 50;
+ this.minSpeed = -10;
+ this.rotationAngle = 4.5;
+ }
+ }
+
+ setRotate(): void {
+ if (this.sogSpeed > this.maxSpeed) {
+ this.rotate = this.maxSpeed * this.rotationAngle;
+ this.setLowIntegrity(true);
+ }
+ else if (this.sogSpeed < this.minSpeed) {
+ this.rotate = this.minSpeed * this.rotationAngle;
+ this.setLowIntegrity(true);
+ }
+ else {
+ this.rotate = isNaN(this.sogSpeed) ? 0 : this.sogSpeed * this.rotationAngle;
+ this.setLowIntegrity(false);
+ }
+ }
+
+ setStwRotate(): void {
+ if (this.stwSpeed > this.maxSpeed) {
+ this.rotateStw = this.maxSpeed * this.rotationAngle;
+ this.setLowIntegrity(true);
+ }
+ else if (this.stwSpeed < this.minSpeed) {
+ this.rotateStw = this.minSpeed * this.rotationAngle;
+ this.setLowIntegrity(true);
+ }
+ else {
+ this.rotateStw = isNaN(this.stwSpeed) ? 0 : this.stwSpeed * this.rotationAngle;
+ this.setLowIntegrity(false);
+ }
+ }
+
+ setLowIntegrity(state: boolean): void {
+ this.lowIntegrity = state;
+ }
+
+ getLowIntegrity(): boolean {
+ return this.lowIntegrity;
+ }
+
+ private renderPrimaryBarArea(barAreas: WatchBarArea[]): SVGTemplateResult[] | typeof nothing {
+ if (barAreas.length === 0) {
+ return nothing;
+ }
+ return barAreas.map((bar, index) => {
+ const startAngle = Math.min(bar.startAngle, bar.endAngle);
+ const endAngle = Math.max(bar.startAngle, bar.endAngle);
+ const arc = roundedArch({
+ r: RING3_RADIUS,
+ R: RING2_RADIUS,
+ startAngle: startAngle,
+ endAngle: endAngle,
+ roundInsideCut: false,
+ roundOutsideCut: false,
+ });
+ // The mask is a sector to cut out the stroke on the start and end of the bar
+ const mask = svg`
+
+
+
+ `;
+ return svg`
+ ${mask}
+
+
+
+ `;
+ });
+ }
+
+ private renderSecondaryBarArea(barAreas: WatchBarArea[]): SVGTemplateResult[] | typeof nothing {
+ if (barAreas.length === 0) {
+ return nothing;
+ }
+ return barAreas.map((bar, index) => {
+ const startAngle = Math.min(bar.startAngle, bar.endAngle);
+ const endAngle = Math.max(bar.startAngle, bar.endAngle);
+ const arc = roundedArch({
+ r: 154,
+ R: 179,
+ startAngle: startAngle,
+ endAngle: endAngle,
+ roundInsideCut: false,
+ roundOutsideCut: false,
+ });
+ // The mask is a sector to cut out the stroke on the start and end of the bar
+ const mask = svg`
+
+
+
+ `;
+ return svg`
+ ${mask}
+
+
+
+ `;
+ });
+ }
+
+ private getSpeedometer() {
+ return svg`
+
+ `;
+ }
+
+ private getWatchFace() {
+ return svg`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ private getAlerts() {
+ if (this.useAlerts) {
+ return svg`
+ ${this.getAdviceContainerLow()}
+ ${this.getAdviceContainerHigh()}
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getAlertLowMask() {
+ if (this.alertLowType != AlertTypes.none && this.useAlerts && this.alertLow > -this.maxSpeed/5) {
+ return svg`
+
+
+
+
+
+
+
+ ${this.alertLowType != AlertTypes.caution ? svg`
+
+
+
+
+
+
+ `
+ : svg`
+
+
+
+
+
+
+
+
+ `
+ }
+
+
+
+
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getAlertHighMask() {
+ if (this.alertHighType != AlertTypes.none && this.useAlerts && this.alertHigh < this.maxSpeed) {
+ return svg`
+
+
+
+
+
+
+
+
+
+
+ ${(this.stwSpeed >= this.alertHigh || this.sogSpeed >= this.alertHigh) ?
+ this.renderPathMaskOfHighAdvice(
+ [
+ {
+ startAngle: this.getAngle(this.alertHigh),
+ endAngle: this.getAngle(this.maxSpeed),
+ fillColor: this.getHighAdviceColor(),
+ }
+ ]
+ )
+ : nothing}
+
+
+
+
+ ${this.alertHighType != AlertTypes.caution ?
+ svg`
+
+ ` :
+ svg`
+
+ `
+ }
+
+
+
+ ${this.alertHighType != AlertTypes.caution ?
+ svg`
+
+
+
+
+
+
+ ` :
+ svg`
+
+
+
+
+
+
+
+
+ `
+ }
+
+
+
+
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getSpeedValue() {
+ if (this.showSpeedAsNumber) {
+ if (this.showSog && this.showStw) {
+ if (this.mainSpeedType == SpeedType.SOG) {
+ return svg`
+
+
+ ${this.getSpeedField({size: InstrumentFieldSize.enhanced, neutralColor: false, horizontal: true, value: this.sogSpeed, fractionDigits: this.fractionDigits, unit: 'kn', tag: 'SOG-R'})}
+
+
+
+
+
+
+
+ ${this.getSpeedField({size: InstrumentFieldSize.enhanced, neutralColor: true, horizontal: true, value: this.stwSpeed, fractionDigits: this.fractionDigits, unit: 'kn', tag: 'STW-R'})}
+
+
+ `;
+ }
+ else {
+ return svg`
+
+
+ ${this.getSpeedField({size: InstrumentFieldSize.enhanced, neutralColor: false, horizontal: true, value: this.stwSpeed, fractionDigits: this.fractionDigits, unit: 'kn', tag: 'STW-R'})}
+
+
+
+
+
+
+
+ ${this.getSpeedField({size: InstrumentFieldSize.enhanced, neutralColor: true, horizontal: true, value: this.sogSpeed, fractionDigits: this.fractionDigits, unit: 'kn', tag: 'SOG-R'})}
+
+
+ `;
+ }
+ }
+ else if (this.showSog && !this.showStw) {
+ return svg`
+
+
+ ${this.getSpeedField({size: InstrumentFieldSize.enhanced, neutralColor: false, horizontal: false, value: this.sogSpeed, fractionDigits: this.fractionDigits, unit: 'kn', tag: 'SOG-R'})}
+
+
+ `
+ }
+ else if (this.showStw && !this.showSog) {
+ return svg`
+
+
+ ${this.getSpeedField({size: InstrumentFieldSize.enhanced, neutralColor: false, horizontal: false, value: this.stwSpeed, fractionDigits: this.fractionDigits, unit: 'kn', tag: 'STW-R'})}
+
+
+ `
+ }
+ else {
+ return nothing;
+ }
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getPrimaryContainer() {
+ if (this.showSog && this.mainSpeedType == SpeedType.SOG && this.sogSpeed != undefined) {
+ return svg`
+
+ ${this.renderPrimaryBarArea([
+ {
+ startAngle: this.getAngle(0),
+ endAngle: this.getAngle(this.getSogSpeed()),
+ fillColor: Colors.instrumentEnhancedTertiary,
+ }])
+ }
+
+
+
+ ${this.getMainNeedle(SpeedType.SOG)}
+
+ `
+ }
+ else if (this.showStw && this.mainSpeedType == SpeedType.STW && this.stwSpeed != undefined) {
+ return svg`
+
+ ${this.renderPrimaryBarArea([
+ {
+ startAngle: this.getAngle(0),
+ endAngle: this.getAngle(this.getStwSpeed()),
+ fillColor: Colors.instrumentEnhancedTertiary,
+ }])
+ }
+
+
+
+ ${this.getMainNeedle(SpeedType.STW)}
+
+ `
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getPrimaryNeedle() {
+ if (this.showSog && this.mainSpeedType == SpeedType.SOG && this.sogSpeed != undefined) {
+ return svg`
+
+ ${this.getMainNeedle(SpeedType.SOG)}
+
+ `
+ }
+ else if (this.showStw && this.mainSpeedType == SpeedType.STW && this.stwSpeed != undefined) {
+ return svg`
+
+ ${this.getMainNeedle(SpeedType.STW)}
+
+ `
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getSecondaryContainer() {
+ if ((this.showStw && (this.mainSpeedType == SpeedType.SOG && this.stwSpeed != undefined) || this.showSog && (this.mainSpeedType == SpeedType.STW && this.sogSpeed != undefined))) {
+ return svg`
+
+ ${this.renderSecondaryBarArea([
+ {
+ startAngle: this.getAngle(0),
+ endAngle: this.getAngle(this.mainSpeedType == SpeedType.SOG ? this.getStwSpeed() : this.getSogSpeed()),
+ fillColor: Colors.instrumentRegularTertiary,
+ },
+ ])}
+
+
+ ${this.mainSpeedType == SpeedType.SOG ? this.getSecondaryNeedle(SpeedType.STW) : this.getSecondaryNeedle(SpeedType.SOG)}
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getSpeedField(instrumentField: InstrumentField) {
+ return html`
+
+ `;
+ }
+
+ private getAdviceContainerHigh() {
+ if (this.useAlerts && this.alertHighType != AlertTypes.none && this.alertHigh < this.maxSpeed) {
+ return svg`
+ ${
+ this.getAlertHighMask()
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${renderAdvice(
+ {
+ minAngle: this.getAngle(this.alertHigh),
+ maxAngle: this.getAngle(this.maxSpeed),
+ type: this.getAdviceType(this.alertHighType),
+ state: this.getHighState(),
+ hideMaxTickmark: true,
+ hideMinTickmark: true
+ }
+ )}
+
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getAdviceContainerLow() {
+ if (this.useAlerts && this.alertLowType != AlertTypes.none && this.alertLow > -this.maxSpeed/5) {
+ return svg`
+ ${
+ this.getAlertLowMask()
+ }
+
+ ${renderAdvice(
+ {
+ minAngle: this.getAngle(this.minSpeed),
+ maxAngle: this.getAngle(this.alertLow),
+ type: this.getAdviceType(this.alertLowType),
+ state: this.getLowState(),
+ hideMaxTickmark: true,
+ hideMinTickmark: true
+ }
+ )}
+
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getMainNeedleFillColor(speedType: SpeedType) {
+ if (speedType == SpeedType.SOG) {
+ if (this.sogSpeed > this.maxSpeed || this.sogSpeed < this.minSpeed) {
+ return Colors.alertCautionColor;
+ }
+ else {
+ return Colors.instrumentFramePrimary;
+ }
+ }
+ else {
+ if (this.stwSpeed > this.maxSpeed || this.stwSpeed < this.minSpeed) {
+ return Colors.alertCautionColor;
+ }
+ else {
+ return Colors.instrumentEnhancedSecondary;
+ }
+ }
+ }
+
+ // add yellow color for needle for low integrity
+ private getMainNeedle(speedType: SpeedType) {
+ return svg`
+
+
+
+ `;
+ }
+
+ private getSecondaryNeedleFillColor(speedType: SpeedType) {
+ if (speedType == SpeedType.SOG) {
+ if (this.sogSpeed > this.maxSpeed || this.sogSpeed < this.minSpeed) {
+ return Colors.alertCautionColor;
+ }
+ else {
+ return Colors.instrumentFrameSecondary;
+ }
+ }
+ else {
+ if (this.stwSpeed > this.maxSpeed || this.stwSpeed < this.minSpeed) {
+ return Colors.alertCautionColor;
+ }
+ else {
+ return Colors.instrumentRegularSecondary;
+ }
+ }
+ }
+
+ private getSecondaryNeedle(speedType: SpeedType) {
+ if (speedType == SpeedType.STW) {
+ return svg`
+
+
+
+
+ `;
+ }
+ else {
+ return svg`
+
+
+
+
+ `;
+ }
+ }
+
+ private getLabels() {
+ if (this.maxSpeed == 25) {
+ return this.getLabels25();
+ }
+ else {
+ return this.getLabels50();
+ }
+ }
+
+ private getLabels25() {
+ return svg`
+
+ `;
+ }
+
+ private getLabels50() {
+ return svg`
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ private getTickmarks() {
+ if (this.maxSpeed == 50) {
+ return this.getTickmarks50();
+ }
+ else {
+ return this.getTickmarks25();
+ }
+ }
+
+ // maybe remove
+ getTickmarks25() {
+ return svg`
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ getTickmarks50() {
+ return svg`
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ getRadiusMask() {
+ return svg`
+
+
+
+
+
+ `;
+ }
+
+ private getHighState() {
+ if (this.showSog && this.sogSpeed >= this.alertHigh) {
+ return AdviceState.triggered;
+ }
+ else if (this.showStw && this.stwSpeed >= this.alertHigh) {
+ return AdviceState.triggered;
+ }
+ else {
+ return AdviceState.hinted;
+ }
+ }
+
+ private getLowState() {
+ if (this.showSog && this.sogSpeed <= this.alertLow) {
+ return AdviceState.triggered;
+ }
+ else if (this.showStw && this.stwSpeed <= this.alertLow) {
+ return AdviceState.triggered;
+ }
+ else {
+ return AdviceState.hinted;
+ }
+ }
+
+ private getAdviceType(alertType: AlertTypes) {
+ if (alertType == AlertTypes.caution) {
+ return AdviceType.caution;
+ }
+ else if(alertType == AlertTypes.warning) {
+ return AdviceType.warning;
+ }
+ else if (alertType == AlertTypes.alarm) {
+ return AdviceType.alarm
+ }
+ else {
+ return AdviceType.advice;
+ }
+ }
+
+ private getHighAdviceColor() {
+ if ((this.sogSpeed < this.alertHigh || !this.showSog) && (this.stwSpeed < this.alertHigh || !this.showStw)) {
+ return Colors.instrumentTickmarkTertiary;
+ }
+ else {
+ if (this.alertHighType == AlertTypes.alarm) {
+ return AlertColor.alarmColor;
+ }
+ else if (this.alertHighType == AlertTypes.warning) {
+ return AlertColor.warningColor;
+ }
+ else if (this.alertHighType == AlertTypes.caution) {
+ return AlertColor.cautionColor;
+ }
+ else {
+ return Colors.instrumentTickmarkTertiary;
+ }
+ }
+ }
+
+ getHighAlertColor() {
+ if (this.sogSpeed >= this.alertHigh) {
+ return this.getAlertColor(this.alertHighType)
+ }
+ else if (this.alertHighType == AlertTypes.none) {
+ return '';
+ }
+ else {
+ return Colors.containerBackdrop;
+ }
+ }
+
+ private getAlertColor(color: AlertTypes) {
+ if (color == AlertTypes.caution) {
+ return AlertColor.cautionColor;
+ }
+ else if (color == AlertTypes.warning) {
+ return AlertColor.warningColor;
+ }
+ else if (color == AlertTypes.alarm) {
+ return AlertColor.alarmColor;
+ }
+ return '';
+ }
+
+ private renderPathMaskOfHighAdvice(barAreas: WatchBarArea[]): SVGTemplateResult[] | typeof nothing {
+ if (barAreas.length === 0 || (Math.abs(barAreas[0].endAngle - barAreas[0].startAngle)) < 4.5) {
+ return nothing;
+ }
+
+ const CX = 477.284;
+ const CY = 429;
+ const RADIUS = 264;
+
+ return barAreas.map((bar) => {
+
+ const startAngle = Math.min(bar.startAngle, bar.endAngle);
+ const endAngle = Math.max(bar.startAngle, bar.endAngle);
+
+ const path = this.describeSector(
+ CX,
+ CY,
+ RADIUS,
+ startAngle,
+ endAngle
+ );
+
+ return svg`
+
+ `;
+ });
+ }
+
+ private describeSector(
+ cx: number,
+ cy: number,
+ r: number,
+ startAngle: number,
+ endAngle: number
+ ): string {
+
+ const toRad = (a: number) => (a - 90) * Math.PI / 180;
+
+ const polar = (angle: number) => ({
+ x: cx + r * Math.cos(toRad(angle)),
+ y: cy + r * Math.sin(toRad(angle)),
+ });
+
+ const start = polar(startAngle);
+ const end = polar(endAngle);
+
+ const largeArc = Math.abs(endAngle - startAngle) > 180 ? 1 : 0;
+
+ return `
+ M ${cx} ${cy}
+ L ${start.x} ${start.y}
+ A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}
+ Z
+ `;
+ }
+
+ cutTickmarks(startAngle: number, endAngle: number) {
+ const CX = 477.284;
+ const CY = 429;
+
+ const OUTER = 289;
+ const INNER = 265;
+
+ const path = this.describeDonutSector(
+ CX,
+ CY,
+ OUTER,
+ INNER,
+ startAngle,
+ endAngle
+ );
+
+ return path;
+ }
+
+ private describeDonutSector(
+ cx: number,
+ cy: number,
+ rOuter: number,
+ rInner: number,
+ startAngle: number,
+ endAngle: number
+ ): string {
+
+ const toRad = (a: number) => (a - 90) * Math.PI / 180;
+
+ const polar = (r: number, angle: number) => ({
+ x: cx + r * Math.cos(toRad(angle)),
+ y: cy + r * Math.sin(toRad(angle)),
+ });
+
+ const p1 = polar(rOuter, startAngle);
+ const p2 = polar(rOuter, endAngle);
+ const p3 = polar(rInner, endAngle);
+ const p4 = polar(rInner, startAngle);
+
+ const largeArc = Math.abs(endAngle - startAngle) > 180 ? 1 : 0;
+
+ return `
+ M ${p1.x} ${p1.y}
+ A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${p2.x} ${p2.y}
+ L ${p3.x} ${p3.y}
+ A ${rInner} ${rInner} 0 ${largeArc} 0 ${p4.x} ${p4.y}
+ Z
+ `;
+ }
+
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'ob-speedometer': Speedometer;
+ }
+}
\ No newline at end of file
diff --git a/packages/openbridge-webcomponents/src/skipper/tickmark.ts b/packages/openbridge-webcomponents/src/skipper/tickmark.ts
new file mode 100644
index 000000000..e11ca542d
--- /dev/null
+++ b/packages/openbridge-webcomponents/src/skipper/tickmark.ts
@@ -0,0 +1,154 @@
+import {SVGTemplateResult, svg} from 'lit';
+
+export interface Tickmark {
+ angle: number;
+ type: TickmarkType;
+ text?: string;
+ color?: string;
+}
+
+export enum TickmarkType {
+ zeroLineThick = 'zeroLineThick',
+ zeroLine = 'zeroLine',
+ main = 'main',
+ primary = 'primary',
+ secondary = 'secondary',
+ tertiary = 'tertiary',
+ textOnly = 'textOnly',
+}
+
+export enum TickmarkStyle {
+ hinted = 'hinted',
+ regular = 'regular',
+ enhanced = 'enhanced',
+ warning = 'warning',
+ alarm = 'alarm',
+}
+
+export function tickmarkColor(style: TickmarkStyle): string {
+ if (style === TickmarkStyle.hinted) {
+ return 'var(--instrument-frame-tertiary-color)';
+ }
+ else if (style === TickmarkStyle.regular) {
+ return 'var(--instrument-tick-mark-tertiary-color)';
+ }
+ else if (style === TickmarkStyle.warning) {
+ return 'var(--on-warning-active-color)';
+ }
+ else if (style === TickmarkStyle.alarm) {
+ return 'var(--on-alarm-active-color)';
+ }
+ else {
+ return 'var(--instrument-tick-mark-primary-color)';
+ }
+}
+
+export function tickmark(
+ angle: number,
+ {
+ size,
+ style,
+ scale,
+ text,
+ inside,
+ textRadius,
+ rotation,
+ maxDigits,
+ color,
+ }: {
+ size: TickmarkType;
+ style: TickmarkStyle;
+ scale: number;
+ text?: string;
+ inside: boolean;
+ textRadius: number;
+ rotation?: number;
+ maxDigits: number;
+ color?: string;
+ }
+): SVGTemplateResult | SVGTemplateResult[] {
+ // check if scale is not infinite
+ if (scale === Infinity || scale <= 0) {
+ throw new Error('Scale is not valid');
+ }
+ let innerRadius: number;
+ let outerRadius: number;
+ textRadius = textRadius + (3 / scale + 3) * (inside ? -1 : 1);
+ const rad = (angle * Math.PI) / 180;
+ if (size === TickmarkType.primary) {
+ innerRadius = 328 / 2;
+ outerRadius = 368 / 2;
+ } else if (size === TickmarkType.secondary) {
+ innerRadius = 328 / 2;
+ outerRadius = 344 / 2;
+ } else if (size === TickmarkType.main || size === TickmarkType.zeroLine) {
+ innerRadius = 320 / 2;
+ outerRadius = 368 / 2;
+ } else if (size === TickmarkType.zeroLineThick) {
+ innerRadius = 224 / 2;
+ outerRadius = 368 / 2;
+ } else if (size === TickmarkType.tertiary) {
+ innerRadius = 328 / 2;
+ outerRadius = 336 / 2;
+ } else {
+ return [textSvg(text ?? '', angle, inside, scale, textRadius)];
+ }
+ const colorName = color ?? tickmarkColor(style);
+
+ const x1 = Math.sin(rad) * innerRadius;
+ const y1 = -Math.cos(rad) * innerRadius;
+ const x2 = Math.sin(rad) * outerRadius;
+ const y2 = -Math.cos(rad) * outerRadius;
+ const strokeWidth =
+ size === TickmarkType.zeroLine || size === TickmarkType.zeroLineThick
+ ? 4
+ : 1;
+ const tick = svg``;
+ if (text) {
+ if (rotation === undefined) {
+ return [tick, textSvg(text, angle, inside, scale, textRadius)];
+ } else {
+ const newRadius =
+ textRadius + ((4 / scale + 5) * (inside ? -1 : 1) * maxDigits) / 2;
+ const textX = Math.sin(rad) * newRadius;
+ const textY = -Math.cos(rad) * newRadius;
+ return [
+ tick,
+ svg`${text}`,
+ ];
+ }
+ }
+ return tick;
+}
+
+function textSvg(
+ text: string,
+ angle: number,
+ inside: boolean,
+ scale: number,
+ textRadius: number
+) {
+ let positionClass = 'top';
+ if (angle === 0) {
+ positionClass = 'top';
+ } else if (angle < 180 && angle > 0) {
+ positionClass = 'right';
+ } else if (angle === 180) {
+ positionClass = 'bottom';
+ } else {
+ positionClass = 'left';
+ }
+ const rad = (angle * Math.PI) / 180;
+ const insideGain = inside ? -1 : 1;
+ const yOffset = (7 / scale) * insideGain;
+ const xOffset = (6 / scale) * insideGain;
+
+ let textX = Math.sin(rad) * (textRadius + xOffset);
+ if (angle > 180) {
+ textX += (4 / scale) * insideGain;
+ } else if (angle < 180 && angle > 0) {
+ textX -= (4 / scale) * insideGain;
+ }
+ const textY = -Math.cos(rad) * (textRadius + yOffset);
+ return svg`${text}`;
+}
diff --git a/packages/openbridge-webcomponents/src/skipper/true-relative/true-relative-styles.ts b/packages/openbridge-webcomponents/src/skipper/true-relative/true-relative-styles.ts
new file mode 100644
index 000000000..30aed6107
--- /dev/null
+++ b/packages/openbridge-webcomponents/src/skipper/true-relative/true-relative-styles.ts
@@ -0,0 +1,75 @@
+import { css } from 'lit';
+export const trueRelativeStyles = css`
+
+ .container {
+ height: 100%;
+ width: 100%;
+ }
+
+ .container > svg {
+ height: 100%;
+ width: 100%;
+ }
+
+ .instrument-field-angle {
+ grid-area: angle;
+ padding-top: 11px;
+ padding-bottom: 4px;
+ }
+
+ .speed-gauge-value {
+ position: relative;
+ font-size: 64;
+ }
+
+ .satellite-label {
+ fill: var(--element-active-inverted-color);
+ font-size: 16px;
+ font-family: sans-serif;
+ text-anchor: middle;
+ dominant-baseline: middle;
+ pointer-events: none;
+ }
+
+ .ring {
+ fill: var(--instrument-enhanced-secondary-color);
+ }
+
+ .ring-dark {
+ fill-opacity: 0.75;
+ }
+
+ .ring-mid {
+ fill-opacity: 0.55;
+ }
+
+ .ring-light {
+ fill-opacity: 0.35;
+ }
+
+ .circle-dashed {
+ fill: none;
+ stroke: var(--instrument-frame-primary-color);
+ stroke-width: 1.2;
+ stroke-dasharray: 6 6;
+ }
+
+ .circle-solid {
+ fill: none;
+ stroke: var(--element-active-color);
+ stroke-width: 2;
+ }
+
+ .axis {
+ stroke: var(--instrument-frame-primary-color);
+ stroke-width: 1.4;
+ }
+
+ .label {
+ fill: var(--element-active-color);
+ font-size: 14px;
+ font-family: sans-serif;
+ text-anchor: middle;
+ dominant-baseline: middle;
+ }
+`;
\ No newline at end of file
diff --git a/packages/openbridge-webcomponents/src/skipper/true-relative/true-relative.stories.ts b/packages/openbridge-webcomponents/src/skipper/true-relative/true-relative.stories.ts
new file mode 100644
index 000000000..7fd5e94d1
--- /dev/null
+++ b/packages/openbridge-webcomponents/src/skipper/true-relative/true-relative.stories.ts
@@ -0,0 +1,146 @@
+import type {Meta, StoryObj} from '@storybook/web-components-vite';
+import {widthDecorator} from '../../storybook-util.js';
+import './true-relative.js';
+import { SensorPosition, SpeedType } from '../interfaces.js';
+import { TrueRelativeDirection } from './true-relative.js';
+
+const meta: Meta = {
+ title: 'INSTRUMENT/TrueRelative',
+ tags: ['alpha'],
+ component: 'ob-true-relative',
+ parameters: {
+ actions: {
+ handles: ['click'],
+ },
+ },
+ args: {
+ width: 512
+ },
+ argTypes: {
+ width: {control: {type: 'range', min: 32, max: 1028, step: 1}},
+ speedType: {
+ options: [
+ SpeedType.SOG,
+ SpeedType.STW
+ ],
+ control: {type: 'select'},
+ },
+ sensorPosition: {
+ options: [
+ SensorPosition.bow,
+ SensorPosition.middle,
+ SensorPosition.aft,
+ ],
+ control: {type: 'select'},
+ },
+ maxSpeed: {control: {type: 'range', min: 1, max: 50, step: 1}},
+ hdgSpeed: {control: {type: 'range', min: 0, max: 100, step: 0.5}},
+ hdgDirection: {control: {type: 'range', min: 0, max: 360, step: 0.5}},
+ cogSpeed: {control: {type: 'range', min: 0, max: 100, step: 0.5}},
+ cogDirection: {control: {type: 'range', min: 0, max: 360, step: 0.5}},
+ windSpeed: {control: {type: 'range', min: 0, max: 100, step: 1}},
+ windAngle: {control: {type: 'range', min: 0, max: 360, step: 0.5}},
+ currentSpeed: {control: {type: 'range', min: 0, max: 3, step: 0.1}},
+ currentAngle: {control: {type: 'range', min: 0, max: 360, step: 0.5}},
+ rotationsPerMinute: {control: {type: 'range', min: -10, max: 10, step: 1}},
+ direction: {
+ control: {type: 'select'},
+ options: Object.values(TrueRelativeDirection),
+ },
+ },
+ decorators: [widthDecorator],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const TrueRelative: Story = {
+ args: {
+ width: 512,
+ speedType: SpeedType.SOG,
+ maxSpeed: 25,
+ hdgSpeed: 23.7,
+ hdgDirection: 0,
+ cogSpeed: 19.7,
+ cogDirection: 25,
+ windSpeed: 17,
+ windAngle: 30,
+ currentSpeed: 0.65,
+ currentAngle: 60,
+ rotationsPerMinute: 5,
+ direction: TrueRelativeDirection.NorthUp,
+ hasNorthArrow: false
+ },
+};
+
+export const NoRot: Story = {
+ args: {
+ width: 512,
+ speedType: SpeedType.SOG,
+ maxSpeed: 25,
+ hdgSpeed: 23.7,
+ hdgDirection: 0,
+ cogSpeed: 19.7,
+ cogDirection: 25,
+ windSpeed: 17,
+ windAngle: 30,
+ currentSpeed: 0.65,
+ currentAngle: 60,
+ rotationsPerMinute: undefined,
+ direction: TrueRelativeDirection.NorthUp,
+ },
+};
+
+export const NoHdg: Story = {
+ args: {
+ width: 512,
+ speedType: SpeedType.SOG,
+ maxSpeed: 25,
+ hdgSpeed: undefined,
+ hdgDirection: 0,
+ cogSpeed: 19.7,
+ cogDirection: 25,
+ windSpeed: 17,
+ windAngle: 30,
+ currentSpeed: 0.65,
+ currentAngle: 60,
+ rotationsPerMinute: undefined,
+ direction: TrueRelativeDirection.NorthUp,
+ },
+};
+
+export const NoCog: Story = {
+ args: {
+ width: 512,
+ speedType: SpeedType.SOG,
+ maxSpeed: 25,
+ hdgSpeed: 23.7,
+ hdgDirection: 0,
+ cogSpeed: 19.7,
+ cogDirection: undefined,
+ windSpeed: 17,
+ windAngle: 30,
+ currentSpeed: 0.65,
+ currentAngle: 60,
+ rotationsPerMinute: undefined,
+ direction: TrueRelativeDirection.NorthUp
+ },
+};
+
+export const NoWindAndCurrent: Story = {
+ args: {
+ width: 512,
+ speedType: SpeedType.SOG,
+ maxSpeed: 25,
+ hdgSpeed: 23.7,
+ hdgDirection: 0,
+ cogSpeed: 19.7,
+ cogDirection: 20.67,
+ windSpeed: undefined,
+ windAngle: 30,
+ currentSpeed: undefined,
+ currentAngle: 60,
+ rotationsPerMinute: 5,
+ direction: TrueRelativeDirection.NorthUp
+ },
+};
\ No newline at end of file
diff --git a/packages/openbridge-webcomponents/src/skipper/true-relative/true-relative.ts b/packages/openbridge-webcomponents/src/skipper/true-relative/true-relative.ts
new file mode 100644
index 000000000..5c697147f
--- /dev/null
+++ b/packages/openbridge-webcomponents/src/skipper/true-relative/true-relative.ts
@@ -0,0 +1,661 @@
+import {LitElement, svg, html, SVGTemplateResult, nothing} from 'lit';
+import {property, state} from 'lit/decorators.js';
+import {customElement} from '../../decorator.js';
+import { trueRelativeStyles } from './true-relative-styles.js';
+import { Colors, SensorPosition, SpeedType, WatchBarArea } from '../interfaces.js';
+import { getWindIcon } from '../wind-icons.js';
+import { getCurrentIcon } from '../current-icons.js';
+
+export enum TrueRelativeDirection {
+ NorthUp = 'northUp',
+ HeadingUp = 'headingUp',
+ CourseUp = 'courseUp',
+}
+
+@customElement('ob-true-relative')
+export class TrueRelative extends LitElement {
+ @property({type: Number}) maxSpeed = 25;
+
+ @property({type: Number}) hdgSpeed = 5;
+ @property({type: Number}) hdgDirection = 0;
+
+ @property({type: Number}) cogSpeed = 5;
+ @property({type: Number}) cogDirection = 25;
+
+ @property({type: String}) speedType = SpeedType.SOG; // TODO: remove
+
+ @property({type: Number}) windSpeed = 5;
+ @property({type: Number}) windAngle = 5;
+ @property({type: Number}) currentSpeed = 5;
+ @property({type: Number}) currentAngle = 5;
+ @property({type: Number}) rotationsPerMinute = 1;
+
+ @property({type: String}) direction: TrueRelativeDirection = TrueRelativeDirection.NorthUp;
+ @property({type: Boolean}) sensorPosition = SensorPosition.bow;
+ @property({type: Boolean}) hasNorthArrow = false;
+
+ center = {x: 362.039, y: 362.039};
+ // 362.039 362.039
+
+ speedRatio = 1;
+
+ private lowIntegrity = false;
+
+ @state()
+ private rot = 0;
+
+ rotSpeed = 10; // °/min
+ endAngle = 0;
+
+ _lastTime = 0;
+ _animId?: number;
+
+ override firstUpdated() {
+ this._lastTime = performance.now();
+ this._animId = requestAnimationFrame(this._animate);
+ }
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this._lastTime = performance.now();
+ this._animate(this._lastTime);
+ }
+
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ cancelAnimationFrame(this._animId!);
+ }
+
+ _animate = (time: number) => {
+ const deltaTime = time - this._lastTime;
+ this._lastTime = time;
+
+ const rotDegPerMs = this.rotSpeed / 60000;
+
+ this.rot = (this.rot + rotDegPerMs * deltaTime) % 360;
+
+ this._animId = requestAnimationFrame(this._animate);
+ };
+
+ override render() {
+ this.setMaxSpeed();
+ this.setSpeedRatio();
+ this.setRotationsPerMinute();
+
+ return html`
+
+ ${this.getTrueRelative()}
+
+ `;
+ }
+
+ static override styles = [
+ trueRelativeStyles
+ ];
+
+ private isValidNumber(value: number | undefined): boolean {
+ return typeof value === 'number' && !isNaN(value);
+ }
+
+ private get isHdgValid() {
+ return this.isValidNumber(this.hdgSpeed) && this.isValidNumber(this.hdgDirection);
+ }
+
+ private get isCogValid() {
+ return this.isValidNumber(this.cogSpeed) && this.isValidNumber(this.cogDirection);
+ }
+
+ private get isRotationsPerMinuteValid() {
+ return this.isValidNumber(this.rotationsPerMinute);
+ }
+
+ private get isWindValid() {
+ return this.isValidNumber(this.windSpeed) && this.isValidNumber(this.windAngle);
+ }
+
+ private get isCurrentValid() {
+ return this.isValidNumber(this.currentSpeed) && this.isValidNumber(this.currentAngle);
+ }
+
+ private getRotation(): number {
+ if (this.direction === TrueRelativeDirection.NorthUp) {
+ return 0;
+ }
+ else if (this.direction === TrueRelativeDirection.HeadingUp) {
+ return -this.hdgDirection;
+ }
+ else if (this.direction === TrueRelativeDirection.CourseUp) {
+ return -this.cogDirection;
+ }
+ else {
+ return 0;
+ }
+ }
+
+ private setMaxSpeed(): void {
+ if (this.maxSpeed < 25) {
+ this.maxSpeed = 25;
+ }
+ else if (this.maxSpeed > 25) {
+ this.maxSpeed = 50;
+ }
+ }
+
+ setSpeedRatio() {
+ this.speedRatio = Math.abs(this.hdgSpeed / this.maxSpeed);
+ }
+
+ setRotationsPerMinute() {
+ if (this.rotationsPerMinute > 10) {
+ this.rotationsPerMinute = 10;
+ }
+ else if (this.rotationsPerMinute <-10) {
+ this.rotationsPerMinute = -10;
+ }
+ }
+
+ setLowIntegrity(state: boolean): void {
+ this.lowIntegrity = state;
+ }
+
+ getLowIntegrity(): boolean {
+ return this.lowIntegrity;
+ }
+
+ private getTrueRelative() {
+ return svg`
+
+ `;
+ }
+
+ private getCogContainer() {
+ if (this.isCogValid && this.cogSpeed != 0) {
+ return svg`
+
+ ${this.renderSogContainer()}
+
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getHdgContainer() {
+ if (this.isHdgValid && this.hdgSpeed != 0) {
+ return svg`
+
+ ${this.getStwArrowMask()}
+ ${this.getStwContainer()}
+
+ `;
+ }
+ else {
+ return nothing;
+ }
+
+ }
+
+ private getRotContainer() {
+ if (this.isRotationsPerMinuteValid && this.rotationsPerMinute != 0) {
+ return svg`
+
+ ${this.getRot()}
+
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getWatchFace() {
+ return svg`
+
+
+ `;
+ }
+
+ getTickmarks() {
+ return svg`
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ private getLabels() {
+ return svg`
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.getNorthArrow()}
+ `;
+ }
+
+ private getNorthArrow() {
+ if (this.hasNorthArrow) {
+ return svg`
+
+
+
+
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getClipPaths() {
+ return svg`
+
+
+
+
+ `;
+ }
+
+ private getVessel() {
+ if (this.isHdgValid) {
+ return svg`
+
+ ${this.getVesselIcon()}
+
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getVesselIcon() {
+ return svg`
+
+
+
+
+
+
+ `;
+ }
+
+ private getSensorPosition() {
+ if (this.sensorPosition == SensorPosition.aft) {
+ return -60;
+ }
+ else if (this.sensorPosition == SensorPosition.middle) {
+ return -20;
+ }
+ else {
+ return 0; // -15;
+ }
+ }
+
+ private getStwArrowMask() {
+ return svg`
+
+
+
+
+ `;
+ }
+
+ private getHdgDirection(stroke: string, strokeWidth: number, dashed = true) {
+ return svg`
+
+
+ `;
+ }
+
+ private getStwArrow(stroke: string, strokeWidth: number, fill?: string) {
+ const cx = 362.039;
+ const cy = 362.039;
+
+ const intensity = Math.min(Math.max(this.hdgSpeed / this.maxSpeed, 0), 1);
+
+ const minY = cy - 10;
+ const maxY = 219;
+ const y = minY - (minY - maxY) * intensity;
+
+ return svg`
+
+
+
+
+
+
+
+ `;
+ }
+
+
+ private getStwContainer() {
+ return svg`
+
+
+ ${this.getHdgDirection(Colors.borderSilhouetteColor, 3, false)}
+ ${this.getStwArrow(Colors.borderSilhouetteColor, 3)}
+
+
+
+
+
+
+ ${this.getHdgDirection(Colors.instrumentEnhancedSecondary, 2, true)}
+ ${this.getStwArrow(Colors.instrumentEnhancedSecondary, 2, Colors.instrumentEnhancedSecondary)}
+
+
+
+ `;
+ }
+
+ private renderSogContainer() {
+ const cx = 362.039;
+ const cy = 362.039;
+
+ const intensity = Math.min(Math.max(this.cogSpeed / this.maxSpeed, 0), 1);
+
+ const minY = cy - 10;
+ const maxY = 219;
+ const y = minY - (minY - maxY) * intensity;
+
+ return svg`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ private getCurrent() {
+ if (this.isCurrentValid) {
+ return svg`
+
+ ${this.renderCurrentIcon()}
+
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ renderCurrentIcon() {
+ const cx = 355.66;
+ const cy = 360.66;
+ const radius = 225;
+
+ return svg`
+
+ ${getCurrentIcon(this.currentSpeed)}
+
+ `;
+ }
+
+ renderWindIcon() {
+ const cx = 357.66;
+ const cy = 360.66;
+ const radius = 225;
+
+ return svg`
+
+ ${getWindIcon(this.windSpeed)}
+
+ `;
+ }
+
+ private getWind() {
+ if (this.isWindValid) {
+ return svg`
+
+ ${this.renderWindIcon()}
+
+ `;
+ }
+ else {
+ return nothing;
+ }
+ }
+
+ private getRot() {
+ this.rotSpeed = this.rotationsPerMinute * 360;
+ this.endAngle = this.rotationsPerMinute * 18;
+
+ const clipId = "rot-bar-mask";
+
+ const CX = 361.66;
+ const CY = 362;
+
+ return svg`
+
+
+
+ ${this.renderPrimaryBarAreaMask([
+ {
+ startAngle: 0,
+ endAngle: this.endAngle + (this.rotationsPerMinute > 0 ? 1.5 : -1.5),
+ fillColor: "white",
+ }
+ ])}
+
+
+
+
+ ${this.renderPrimaryBarArea([
+ {
+ startAngle: 0,
+ endAngle: this.endAngle,
+ fillColor: Colors.instrumentRegularTertiary,
+ }
+ ])}
+
+
+
+
+
+
+
+
+ ${[0, 72, 144, 216, 288].map(a => {
+ const r = 176;
+ const rad = (a - 90) * Math.PI / 180;
+ const x = r * Math.cos(rad);
+ const y = r * Math.sin(rad);
+
+ return svg`
+
+ `;
+ })}
+
+
+
+
+
+ `;
+ }
+
+ private renderPrimaryBarArea(barAreas: WatchBarArea[]): SVGTemplateResult[] | typeof nothing {
+ if (!barAreas.length) return nothing;
+
+ return barAreas.map(bar => {
+ const start = Math.min(bar.startAngle, bar.endAngle);
+ const end = Math.max(bar.startAngle, bar.endAngle);
+
+ const d = this.describeArc(168, 184, start, end);
+
+ return svg`
+
+ `;
+ });
+ }
+
+ private renderPrimaryBarAreaMask(barAreas: WatchBarArea[]): SVGTemplateResult[] | typeof nothing {
+ if (!barAreas.length) return nothing;
+
+ return barAreas.map(bar => {
+ const start = Math.min(bar.startAngle, bar.endAngle);
+ const end = Math.max(bar.startAngle, bar.endAngle);
+
+ const d = this.describeArc(164, 180, start, end); // malo veći kao OpenBridge
+
+ return svg`
+
+ `;
+ });
+ }
+
+ private describeArc(r: number, R: number, startAngle: number, endAngle: number) {
+ const toRad = (a: number) => (a - 90) * Math.PI / 180;
+
+ const largeArc = endAngle - startAngle > 180 ? 1 : 0;
+
+ const x1 = R * Math.cos(toRad(startAngle));
+ const y1 = R * Math.sin(toRad(startAngle));
+
+ const x2 = R * Math.cos(toRad(endAngle));
+ const y2 = R * Math.sin(toRad(endAngle));
+
+ const x3 = r * Math.cos(toRad(endAngle));
+ const y3 = r * Math.sin(toRad(endAngle));
+
+ const x4 = r * Math.cos(toRad(startAngle));
+ const y4 = r * Math.sin(toRad(startAngle));
+
+ return `
+ M ${x1} ${y1}
+ A ${R} ${R} 0 ${largeArc} 1 ${x2} ${y2}
+ L ${x3} ${y3}
+ A ${r} ${r} 0 ${largeArc} 0 ${x4} ${y4}
+ Z
+ `;
+ }
+
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'ob-true-relative': TrueRelative;
+ }
+}
\ No newline at end of file
diff --git a/packages/openbridge-webcomponents/src/skipper/utils/storybook-helpers.ts b/packages/openbridge-webcomponents/src/skipper/utils/storybook-helpers.ts
new file mode 100644
index 000000000..a2f028665
--- /dev/null
+++ b/packages/openbridge-webcomponents/src/skipper/utils/storybook-helpers.ts
@@ -0,0 +1,15 @@
+export function mapBooleanArgs(args: any): any {
+ const out = { ...args };
+ Object.entries(args).forEach(
+ (entry) => {
+ const k = entry[0];
+ let v = entry[1];
+ if (v === true) {
+ v = ''
+ } else if (v === false) {
+ v = undefined
+ }
+ out[k] = v
+ })
+ return out
+}
\ No newline at end of file
diff --git a/packages/openbridge-webcomponents/src/skipper/utils/uuid.ts b/packages/openbridge-webcomponents/src/skipper/utils/uuid.ts
new file mode 100644
index 000000000..d74b37ad5
--- /dev/null
+++ b/packages/openbridge-webcomponents/src/skipper/utils/uuid.ts
@@ -0,0 +1,6 @@
+export function uuidv4() {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+ const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
+ return v.toString(16);
+ });
+}
\ No newline at end of file
diff --git a/packages/openbridge-webcomponents/src/skipper/vessel-view/vessel-styles.ts b/packages/openbridge-webcomponents/src/skipper/vessel-view/vessel-styles.ts
new file mode 100644
index 000000000..ddfdf0ea3
--- /dev/null
+++ b/packages/openbridge-webcomponents/src/skipper/vessel-view/vessel-styles.ts
@@ -0,0 +1,85 @@
+import {css} from 'lit';
+export const vesselStyles = css`
+ .container {
+ height: 100%;
+ width: 100%;
+ }
+
+ .container > svg {
+ height: 100%;
+ width: 100%;
+ }
+
+ .satellite-label {
+ fill: var(--element-active-inverted-color);
+ font-size: 16px;
+ font-family: sans-serif;
+ text-anchor: middle;
+ dominant-baseline: middle;
+ pointer-events: none;
+ }
+
+ .ring {
+ fill: var(--instrument-enhanced-secondary-color);
+ }
+
+ .ring-dark {
+ fill-opacity: 0.75;
+ }
+
+ .ring-mid {
+ fill-opacity: 0.55;
+ }
+
+ .ring-light {
+ fill-opacity: 0.35;
+ }
+
+ .circle-dashed {
+ fill: none;
+ stroke: var(--instrument-frame-primary-color);
+ stroke-width: 1.2;
+ stroke-dasharray: 6 6;
+ }
+
+ .circle-solid {
+ fill: none;
+ stroke: var(--element-active-color);
+ stroke-width: 2;
+ }
+
+ .axis {
+ stroke: var(--instrument-frame-primary-color);
+ stroke-width: 1.4;
+ }
+
+ .label {
+ fill: var(--element-active-color);
+ font-size: 14px;
+ font-family: sans-serif;
+ text-anchor: middle;
+ dominant-baseline: middle;
+ }
+
+ .both-views {
+ width: 400px;
+ overflow-x: hidden;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .both-views-inner {
+ transform: scale(0.69);
+ transform-origin: center;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .top-down-view {
+ margin-left: 60px;
+ }
+`;
diff --git a/packages/openbridge-webcomponents/src/skipper/vessel-view/vessel-types.ts b/packages/openbridge-webcomponents/src/skipper/vessel-view/vessel-types.ts
new file mode 100644
index 000000000..b19f9e2bc
--- /dev/null
+++ b/packages/openbridge-webcomponents/src/skipper/vessel-view/vessel-types.ts
@@ -0,0 +1,827 @@
+import {nothing, svg, type TemplateResult} from 'lit';
+import {Colors} from '../interfaces.js';
+import {VesselTypes, ViewType} from './vessel.js';
+
+type VesselTypeData = {
+ side: TemplateResult;
+ topDown: TemplateResult;
+ sideTopY: number;
+ sideBottomY: number;
+};
+
+type DimHeightVesselParams = {
+ vesselHeight: number;
+ textY: number;
+ arrowPath: string;
+ lineStart: number;
+ lineEnd: number;
+};
+
+type getSideViewParams = {
+ sensorX: number; //!!!
+ sensorY: number; //!!!
+ mastEndY: number;
+ verticalGuideStartY: number;
+ verticalGuideEndY: number;
+ ARCH_X: number;
+ ARCH_Y: number;
+ sensorHeightOverKeel: number; //!!!
+ sternTextX: number;
+ sternLine: {
+ startX: number;
+ endX: number;
+ arrowPath: string;
+ textX: number;
+ y: number;
+ };
+
+ ARCH_BOTTOM_Y: number;
+ bowTextX: number;
+ bowLine: {
+ startX: number;
+ endX: number;
+ arrowPath: string;
+ textX: number;
+ y: number;
+ };
+ sensorToCCRP: number;
+
+ dim: {
+ arrowPath: string;
+ startX: number;
+ endX: number;
+ y: number;
+ textX: number;
+ }; //!!!
+ coneLeftX: number;
+ coneRightX: number;
+ coneBottomY: number;
+ toggleSLAndDLSensor: boolean;
+ textOffset: number; //!!!
+ ccrpX: number; //!!!
+
+ vesselLengthComputed: number;
+ sternToCCRP: number;
+ bowToCCRP: number;
+ vesselHeight: number;
+};
+
+type getTopDownViewParams = {
+ sensorX: number;
+ ccrpX: number;
+ sensorHeightOverKeel: number;
+ toggleSLAndDLSensor: boolean;
+ vesselLengthComputed: number;
+
+ sensorYTopView: number;
+ sensorPortStarboardOffset: number;
+ sensorDim: {
+ arrowPath: string;
+ lineStart: number;
+ lineEnd: number;
+ textY: number;
+ };
+ DIM_X: number;
+ showSensorDim: boolean;
+ yAxisCCRPPos: number;
+ starboardDimensionLine: {startY: number; endY: number};
+ getArrowHalfHeight: () => number;
+ ARROW_SIZE: number;
+ getMidY: (start: number, end: number) => number;
+ starboardToCCRP: number;
+ portDimensionLine: {startY: number; endY: number};
+ portToCCRP: number;
+ vesselWidthComputed: number;
+};
+
+type sensorPositionParams = {
+ sensorX: number;
+ sensorY: number;
+ sensorHeightOverKeel: number;
+ toggleSLAndDLSensor: boolean;
+ sensorToCCRP: number;
+ dim: {
+ arrowPath: string;
+ startX: number;
+ endX: number;
+ y: number;
+ textX: number;
+ };
+ textOffset: number;
+ viewMode: ViewType;
+};
+
+type dimPortToCCRPParams = {
+ portDimensionLine: {startY: number; endY: number};
+ getMidY: (startY: number, endY: number) => number;
+ portToCCRP: number;
+ ARROW_SIZE: number;
+ getArrowHalfHeight: () => number;
+};
+
+type dimStarboardToCCRPParams = {
+ starboardDimensionLine: {startY: number; endY: number};
+ getMidY: (startY: number, endY: number) => number;
+ starboardToCCRP: number;
+ ARROW_SIZE: number;
+ getArrowHalfHeight: () => number;
+};
+
+type horizontalCCRPParams = {yAxisCCRPPos: number};
+
+type verticalCCRPParams = {xAxisCCRPPos: number};
+
+type sensorPosTopDownParams = {
+ sensorX: number;
+ sensorYTopView: number;
+};
+
+export function getVesselType(vesselType: string): VesselTypeData {
+ if (vesselType === VesselTypes.TANKER) {
+ const {TANKER_SIDE_VIEW, TANKER_TOP_DOWN_VIEW} = getVesselTanker();
+
+ return {
+ side: TANKER_SIDE_VIEW,
+ topDown: TANKER_TOP_DOWN_VIEW,
+ sideTopY: 117,
+ sideBottomY: 261,
+ };
+ } else if (vesselType === VesselTypes.CARGO) {
+ const {CARGO_SIDE_VIEW, CARGO_TOP_DOWN_VIEW} = getVesselCargo();
+
+ return {
+ side: CARGO_SIDE_VIEW,
+ topDown: CARGO_TOP_DOWN_VIEW,
+ sideTopY: 117,
+ sideBottomY: 261,
+ };
+ } else if (vesselType === VesselTypes.CAR_FERRY) {
+ const {CAR_FERRY_SIDE_VIEW, CAR_FERRY_TOP_DOWN_VIEW} = getVesselCarFerry();
+
+ return {
+ side: CAR_FERRY_SIDE_VIEW,
+ topDown: CAR_FERRY_TOP_DOWN_VIEW,
+ sideTopY: 180,
+ sideBottomY: 264,
+ };
+ } else if (vesselType === VesselTypes.FISHING) {
+ const {FISHING_SIDE_VIEW, FISHING_TOP_DOWN_VIEW} = getVesselFishing();
+
+ return {
+ side: FISHING_SIDE_VIEW,
+ topDown: FISHING_TOP_DOWN_VIEW,
+ sideTopY: 126,
+ sideBottomY: 270,
+ };
+ }
+
+ return {
+ side: svg``,
+ topDown: svg``,
+ sideTopY: 117,
+ sideBottomY: 261,
+ };
+}
+
+function sensorPositioning(
+ sensorPosParams: sensorPositionParams
+): TemplateResult {
+ return svg`
+ Sensor
+ ${
+ sensorPosParams.sensorToCCRP !== 0
+ ? svg`
+
+
+
+
+
+
+
+ ${Math.abs(sensorPosParams.sensorToCCRP)}
+
+
+ `
+ : nothing
+ }
+ `;
+}
+
+function dimLengthLineSideView(totalLength: number): TemplateResult {
+ return svg`
+ ${totalLength}
+
+ `;
+}
+
+function dimHeightVesselSide(params: DimHeightVesselParams): TemplateResult {
+ return svg`
+
+ ${params.vesselHeight}
+
+
+
+
+
+
+`;
+}
+
+function dimSternToCCRPSide(sideViewParams: getSideViewParams): TemplateResult {
+ return svg`${textLabel({
+ posX: sideViewParams.sternTextX - 7,
+ posY: 297.656,
+ label: sideViewParams.sternToCCRP,
+ })}
+
+ `;
+}
+
+function dimBowToCCRPSide(sideViewParams: getSideViewParams): TemplateResult {
+ return svg`${textLabel({
+ posX: sideViewParams.bowTextX - 7,
+ posY: 297.656,
+ label: sideViewParams.bowToCCRP,
+ })}
+
+ `;
+}
+
+function dimHeightSensorIndicatorSide(
+ sideViewParams: getSideViewParams,
+ keelRefPoint: boolean
+): TemplateResult {
+ return svg`
+
+
+ ${textLabel({posX: 6.5, posY: 15.656, label: !keelRefPoint ? sideViewParams.sensorHeightOverKeel : 'Keel'})}
+
+ `;
+}
+
+function getSensorCone(sideViewParams: getSideViewParams): TemplateResult {
+ return svg`
+
+
+ `;
+}
+
+export function getSideView(
+ vesselType: string,
+ sideViewParams: getSideViewParams,
+ sensorPos: sensorPositionParams,
+ dimHeightVesselParams: DimHeightVesselParams
+): TemplateResult {
+ const VESSEL_TYPE = getVesselType(vesselType);
+ return svg` `;
+}
+///
+//
+/*
+TOP DOWN VIEW FUNCTIONS
+*/
+function dimLengthLineTopView(totalLength: number): TemplateResult {
+ return svg`
+ ${textLabel({posX: 230, posY: 314.156, label: totalLength})}
+
+ `;
+}
+
+function dimWidthLineTopView(totalWidth: number): TemplateResult {
+ return svg`
+ ${textLabel({posX: 0, posY: 237, label: totalWidth})}
+
+ `;
+}
+
+function dimPortToCCRPLine(dimPortParams: dimPortToCCRPParams): TemplateResult {
+ return svg`
+ ${textLabel({
+ posX: 448,
+ posY: dimPortParams.getMidY(
+ dimPortParams.portDimensionLine.startY,
+ dimPortParams.portDimensionLine.endY
+ ),
+ label: dimPortParams.portToCCRP,
+ })}
+
+
+
+ `;
+}
+
+function dimStarboardToCCRPLine(
+ dimStarboardParams: dimStarboardToCCRPParams
+): TemplateResult {
+ return svg`
+ ${textLabel({
+ posX: 448,
+ posY: dimStarboardParams.getMidY(
+ dimStarboardParams.starboardDimensionLine.startY,
+ dimStarboardParams.starboardDimensionLine.endY
+ ),
+ label: dimStarboardParams.starboardToCCRP,
+ })}
+
+
+ `;
+}
+
+function horizontalCCRPTopView(
+ yAxisCCRPParams: horizontalCCRPParams
+): TemplateResult {
+ return svg`
+ ${textLabel({posX: 475, posY: yAxisCCRPParams.yAxisCCRPPos, label: 'CCRP'})}`;
+}
+
+function verticalCCRPTopView(
+ xAxisCCRPParams: verticalCCRPParams
+): TemplateResult {
+ return svg`
+ ${textLabel({posX: xAxisCCRPParams.xAxisCCRPPos - 15, posY: 296.156, label: 'CCRP'})}
+ `;
+}
+
+function sensorPositionTopView(
+ sensorPos: sensorPosTopDownParams
+): TemplateResult {
+ return svg`
+
+
+ `;
+}
+
+function textLabel(position: {
+ posX: number;
+ posY: number;
+ label: string | number;
+ fontWeight?: 'bold';
+}): TemplateResult {
+ return svg`
+ ${position.label}
+ `;
+}
+
+function getSensorHeightIndicator(
+ topDownViewParams: getTopDownViewParams
+): TemplateResult {
+ return svg`
+
+
+
+
+
+ ${textLabel({
+ posX: 177,
+ posY: 165.156,
+ label: topDownViewParams.toggleSLAndDLSensor
+ ? 'Keel'
+ : '+' + topDownViewParams.sensorHeightOverKeel,
+ })}
+
+ `;
+}
+
+function verticalDimSensorToCCRPLine(
+ topDownViewParams: getTopDownViewParams
+): TemplateResult {
+ return svg`
+
+
+
+ `;
+}
+
+export function getTopDownView(
+ vesselType: string,
+ topDownViewParams: getTopDownViewParams,
+ dimPortToCCRPParams: dimPortToCCRPParams,
+ dimStarboardToCCRPParams: dimStarboardToCCRPParams,
+ horizontalCCRPParams: horizontalCCRPParams,
+ verticalCCRPParams: verticalCCRPParams,
+ sensorPosTopDownParams: sensorPosTopDownParams
+): TemplateResult {
+ const VESSEL_TYPE = getVesselType(vesselType);
+ return svg`
+ ${getSensorHeightIndicator(topDownViewParams)}
+