Single-axis inclinometer
The shared renderer for pitch and roll, with an arc scale, an average band, caution zones, an indicator and a readout.
On this page
Code
import {
OUTER_RING_RADIUS,
Priority,
SingleAxisInclinometer,
VesselImage,
VesselImageSize,
vesselImages,
type InclinometerAxis,
} from "@bosunkit/instruments";
import { useEffect, useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Two axes built by hand on a live feed: heel on a bottom arc with both scales, the yacht and the caution zones, " +
"trim on a zoomed right arc with the vessel drawn in the center, and the heel as a number that shows a dash " +
"while the motion sensor drops out.";
const SAMPLE_SECONDS: number = 0.5;
const TREND_SECONDS: number = 10;
const HEEL_CAUTION: number = 20;
const RADIUS: number = OUTER_RING_RADIUS;
const PANEL: CSSProperties = {
display: "flex",
flexWrap: "wrap",
alignItems: "flex-end",
justifyContent: "center",
gap: 24,
width: "100%",
maxWidth: 660,
};
const FIGURE: CSSProperties = { display: "flex", flexDirection: "column", alignItems: "center", gap: 6, margin: 0 };
const CAPTION: CSSProperties = { fontSize: 13, color: "var(--text-dim)", textAlign: "center", maxWidth: 200 };
function heelAt(time: number): number {
const gust: number = Math.max(0, Math.sin(time / 6)) ** 3;
return 14 + 8 * gust + 3 * Math.sin((2 * Math.PI * time) / 6.5);
}
function trimAt(time: number): number {
return 1.5 + 4 * Math.sin((2 * Math.PI * time) / 5.5);
}
function bandAt(time: number, valueAt: (time: number) => number): { low: number; high: number } {
let low: number = Number.POSITIVE_INFINITY;
let high: number = Number.NEGATIVE_INFINITY;
for (let past: number = 0; past <= TREND_SECONDS; past += SAMPLE_SECONDS) {
const value: number = valueAt(time - past);
low = Math.min(low, value);
high = Math.max(high, value);
}
return { low, high };
}
function rotate(degrees: number): string {
return "rotate(" + String(degrees) + "deg)";
}
/** A heel axis on the bottom arc: the bow-on yacht, a vertical indicator and the thin ring above the arc. */
function heelAxis(heel: number, low: number, high: number, readoutValue: number): InclinometerAxis {
return {
centerAngle: 180,
value: heel,
readoutValue,
avgMin: low,
avgMax: high,
maxAdvice: HEEL_CAUTION,
triggerAdvice: high > HEEL_CAUTION,
defaultAdviceOuter: 45,
isDualScale: true,
scaleVessels: [{ size: VesselImageSize.large, vesselImage: VesselImage.sailingYachtFore, transform: rotate(heel) }],
label: "Heel",
unit: "DEG",
fractionDigits: 1,
renderIndicator: (needleTransform: string, indicatorColor: string): ReactElement => (
<line x1={0} y1={0} x2={0} y2={RADIUS - 10} stroke={indicatorColor} transform={needleTransform} />
),
renderVesselOverlay: (): null => null,
renderComplement: (arcAngle: number): ReactElement => {
const x: number = RADIUS * Math.sin((arcAngle * Math.PI) / 180);
const y: number = RADIUS * Math.cos((arcAngle * Math.PI) / 180);
return (
<path
d={["M", -x, y, "A", RADIUS, RADIUS, 0, 1, 1, x, y].join(" ")}
fill="none"
stroke="var(--instrument-frame-tertiary-color)"
/>
);
},
};
}
/** A trim axis on the right arc, drawn zoomed: the side-view vessel sits in the center overlay. */
function trimAxis(trim: number, low: number, high: number): InclinometerAxis {
return {
centerAngle: 90,
value: trim,
avgMin: low,
avgMax: high,
maxAdvice: 5,
triggerAdvice: Math.max(Math.abs(low), Math.abs(high)) > 5,
defaultAdviceOuter: 30,
isDualScale: false,
scaleVessels: [],
label: "Trim",
unit: "DEG",
fractionDigits: 1,
renderIndicator: (needleTransform: string, indicatorColor: string): ReactElement => (
<line x1={0} y1={0} x2={RADIUS - 10} y2={0} stroke={indicatorColor} transform={needleTransform} />
),
renderVesselOverlay: (vesselScale: number): ReactElement => (
<g
style={{ transform: rotate(trim) + " scale(" + String(vesselScale) + ") translate(-80px, -80px)" }}
dangerouslySetInnerHTML={{ __html: vesselImages[VesselImage.genericSide] }}
/>
),
renderComplement: (): null => null,
};
}
export default function Showcase(): ReactElement {
const [sample, setSample]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
useEffect((): (() => void) => {
const interval: number = window.setInterval((): void => {
setSample((previous: number): number => previous + 1);
}, SAMPLE_SECONDS * 1000);
return (): void => {
window.clearInterval(interval);
};
}, []);
const time: number = sample * SAMPLE_SECONDS;
const heel: number = heelAt(time);
const heelBand: { low: number; high: number } = bandAt(time, heelAt);
const trim: number = trimAt(time);
const trimBand: { low: number; high: number } = bandAt(time, trimAt);
// The motion sensor drops out for three seconds in every fifteen.
const sensorSilent: boolean = time % 15 >= 12;
return (
<div style={PANEL}>
<figure style={FIGURE}>
<div style={{ width: 250, height: 250 }}>
<SingleAxisInclinometer
axis={heelAxis(heel, heelBand.low, heelBand.high, heel)}
priority={Priority.enhanced}
/>
</div>
<figcaption style={CAPTION}>Heel: both scales, the yacht and the caution zones</figcaption>
</figure>
<figure style={FIGURE}>
<div style={{ width: 170, height: 170 }}>
<SingleAxisInclinometer axis={trimAxis(trim, trimBand.low, trimBand.high)} arcAngle={25} zoomToFitArc />
</div>
<figcaption style={CAPTION}>Trim: a 50 degree arc, zoomed, with the vessel in the center</figcaption>
</figure>
<figure style={FIGURE}>
<div style={{ width: 150, height: 150 }}>
<SingleAxisInclinometer
axis={heelAxis(heel, heelBand.low, heelBand.high, sensorSilent ? Number.NaN : heel)}
hasReadout
priority={Priority.enhanced}
/>
</div>
<figcaption style={CAPTION}>Heel as a number, a dash while the sensor is silent</figcaption>
</figure>
</div>
);
}
The single-axis inclinometer draws one angle on an arc: the scale, the band of recent average values, the caution
zones, the rotating indicator and the optional center readout. Pitch and Roll are thin wrappers that build the
axis prop, which sets where the arc is centered and how the indicator and the vessel are drawn. A screen for trim or
heel uses Pitch or Roll. It ports the single-axis inclinometer
building block of obc-pitch and obc-roll; the overview lists the other instruments.
Examples
Inclinometer
Code
import { SingleAxisInclinometer, type InclinometerAxis } from "@bosunkit/instruments";
import type { ReactElement } from "react";
export const description: string = "A 12 degree reading on a right-hand arc, shown as a number in the center.";
const AXIS: InclinometerAxis = {
centerAngle: 90,
value: 12,
avgMin: 0,
avgMax: 0,
maxAdvice: undefined,
triggerAdvice: false,
defaultAdviceOuter: 30,
isDualScale: false,
scaleVessels: [],
label: "Trim",
unit: "DEG",
fractionDigits: 0,
renderIndicator: (): null => null,
renderVesselOverlay: (): null => null,
renderComplement: (): null => null,
};
export default function Inclinometer(): ReactElement {
return (
<div style={{ width: 200, height: 200 }}>
<SingleAxisInclinometer axis={AXIS} hasReadout />
</div>
);
}
Use the domain wrapper
Pitch and Roll supply established axis conventions and vessel views. Use this primitive for another axis only when that axis has an explicit sign, reference and label; a rotated silhouette alone does not define them.
The caller supplies the recent-average band and caution settings. State the averaging window and preserve the current measurement separately. Test both signs, bounds and unavailable data, including the textual reading that makes the view understandable without relying on the silhouette.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| zoomToFitArc | boolean | undefined | false | Enlarges the visible arc to fill the container; the vessel and the indicator keep their natural size. Default
false. |
| hasReadout | boolean | undefined | false | When true, the center shows a readout with the value instead of the
horizon line, rotating indicator and vessel. Default false. |
| priority | Priority | undefined | Priority.regular | Color palette for the scale fill / indicator and the readout value:
regular (default) or enhanced. |
| arcAngle | number | undefined | 45 | Half-extent of the watch arc in degrees. The arc spans
center ± arcAngle and values are placed at their true position within
it. Default 45 reproduces the historical 90°-wide arc. Combined with
zoomToFitArc, the narrower arc is enlarged on its own layer while the
vessel and indicator stay at natural size on the central layer. |
| axisrequired | InclinometerAxis | – | The axis configuration that Pitch and Roll supply: where the arc is centered, the measured value and average
band in degrees, the caution thresholds, and the vessel and indicator render hooks. |
| className | string | undefined | – | Class names added to the instrument's root element. |