Skip to content
design
Instruments

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
Heel: both scales, the yacht and the caution zones
Trim: a 50 degree arc, zoomed, with the vessel in the center
14.0
Heel
DEG
Heel as a number, a dash while the sensor is silent
Heel and trim axes built by hand on a live feed: both scales, the vessel, caution zones, a zoomed arc and a readout that shows a dash while the sensor drops out.
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

12
Trim
DEG
A 12 degree reading on a right-hand arc, shown as a number in the center.
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

PropTypeDefaultDescription
zoomToFitArcboolean | undefinedfalseEnlarges the visible arc to fill the container; the vessel and the indicator keep their natural size. Default false.
hasReadoutboolean | undefinedfalseWhen true, the center shows a readout with the value instead of the horizon line, rotating indicator and vessel. Default false.
priorityPriority | undefinedPriority.regularColor palette for the scale fill / indicator and the readout value: regular (default) or enhanced.
arcAnglenumber | undefined45Half-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.
axisrequiredInclinometerAxis–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.
classNamestring | undefined–Class names added to the instrument's root element.