Skip to content
design
Instruments

Watch

The circular face that the radial instruments draw on, with rings, tickmarks, arcs, needles, setpoints and wind and current arrows.

On this page
0306090120150180150120906030
Apparent wind: needle, target band and setpoint, no-go zone, true wind
N ESWN
Compass rose, heading up, with the course being adjusted
6060
Close-hauled arc, zoomed, ticks fading at the ends
Saloon repeater, off, the top half only
Three sailing instruments built on the bare face, beating to windward on a live feed, beside a repeater that is switched off.
Code
import {
  AdviceState,
  AdviceType,
  InstrumentState,
  OUTER_RING_RADIUS,
  Priority,
  RotPosition,
  RotType,
  TickmarkStyle,
  TickmarkType,
  VesselImage,
  VesselImageSize,
  Watch,
  WatchCircleType,
  type AngleAdviceRaw,
  type Tickmark,
  type WatchArea,
  type WatchBarArea,
  type WatchNeedle,
  type WatchVessel,
} from "@bosunkit/instruments";
import { useEffect, useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Three sailing instruments built on the bare face, on one live feed beating to windward: an apparent wind dial " +
  "with no-go and target zones, a heading-up compass rose turning under the bow, and a close-hauled arc zoomed onto " +
  "the upwind sector, beside a repeater that is switched off.";

/** The apparent wind angle the helmsman steers to when close-hauled. */
const TARGET_ANGLE: number = 38;

const PANEL: CSSProperties = {
  display: "grid",
  gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
  gap: 20,
  alignItems: "end",
  justifyItems: "center",
  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: 220 };

/** Relative bearings every 30 degrees, labeled port and starboard as a sailor reads them. */
const WIND_TICKMARKS: Tickmark[] = Array.from({ length: 12 }, (_: unknown, index: number): Tickmark => {
  const angle: number = index * 30;
  const relative: number = angle > 180 ? 360 - angle : angle;
  return {
    angle,
    type: angle % 90 === 0 ? TickmarkType.primary : TickmarkType.secondary,
    text: String(relative),
  };
});

/** Every 10 degrees across the close-hauled arc, with its two ends labeled. */
const ARC_TICKMARKS: Tickmark[] = Array.from({ length: 13 }, (_: unknown, index: number): Tickmark => {
  const angle: number = -60 + index * 10;
  return {
    angle,
    type: angle === 0 ? TickmarkType.zeroLine : TickmarkType.secondary,
    text: Math.abs(angle) === 60 ? "60" : undefined,
  };
});

const CLOSE_HAULED_ARC: WatchArea[] = [{ startAngle: -60, endAngle: 60, roundOutsideCut: true, roundInsideCut: true }];

/** The no-go zone either side of the bow, and the target bands either side of it. */
function adviceFor(windAngle: number): AngleAdviceRaw[] {
  const inNoGo: boolean = Math.abs(windAngle) < 30;
  const onTarget: boolean = Math.abs(Math.abs(windAngle) - TARGET_ANGLE) <= 5;
  return [
    {
      minAngle: -30,
      maxAngle: 30,
      type: AdviceType.caution,
      state: inNoGo ? AdviceState.triggered : AdviceState.hinted,
    },
    {
      minAngle: 33,
      maxAngle: 43,
      type: AdviceType.advice,
      state: onTarget ? AdviceState.triggered : AdviceState.regular,
    },
    { minAngle: -43, maxAngle: -33, type: AdviceType.advice, state: AdviceState.hinted },
  ];
}

export default function Showcase(): ReactElement {
  const [second, setSecond]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);

  useEffect((): (() => void) => {
    const interval: number = window.setInterval((): void => {
      setSecond((previous: number): number => previous + 1);
    }, 1000);
    return (): void => {
      window.clearInterval(interval);
    };
  }, []);

  // On starboard tack the wind comes over the starboard bow; the helmsman pinches and bears away around the target.
  const windAngle: number = TARGET_ANGLE + 7 * Math.sin(second / 4) + 2 * Math.sin(second * 1.3);
  const trueWindAngle: number = windAngle + 14;
  const heading: number = 318 + 5 * Math.sin(second / 4);
  const rateOfTurn: number = (5 / 4) * Math.cos(second / 4) * 60;
  const windKnots: number = 17 + 4 * Math.sin(second / 7);
  const adjusting: boolean = second % 12 >= 10;

  const windNeedle: WatchNeedle[] = [
    {
      angle: windAngle,
      fillColor: "var(--instrument-enhanced-secondary-color)",
      strokeColor: "var(--border-outline-color)",
    },
  ];
  const sailBand: WatchBarArea[] = [
    {
      startAngle: TARGET_ANGLE - 5,
      endAngle: TARGET_ANGLE + 5,
      fillColor: "var(--instrument-enhanced-tertiary-color)",
    },
  ];
  const vessel: WatchVessel[] = [
    { size: VesselImageSize.medium, vesselImage: VesselImage.genericTop, transform: "rotate(0deg)" },
  ];

  return (
    <div style={PANEL}>
      <figure style={FIGURE}>
        <div style={{ width: 230, height: 230 }}>
          <Watch
            watchCircleType={WatchCircleType.triple}
            priority={Priority.enhanced}
            starboardPortIndicator
            tickmarks={WIND_TICKMARKS}
            tickmarkStyle={TickmarkStyle.enhanced}
            advices={adviceFor(windAngle)}
            needles={windNeedle}
            barAreas={sailBand}
            angleSetpoint={TARGET_ANGLE}
            atAngleSetpoint={Math.abs(windAngle - TARGET_ANGLE) <= 2}
            angleSetpointAtZeroDeadband={0.5}
            vessels={vessel}
            windKnots={windKnots}
            windFromDirectionDeg={trueWindAngle}
            windColor="var(--instrument-enhanced-primary-color)"
            scaleWindIcon={0.9}
            current={2}
            currentFromDirectionDeg={200}
            currentColor="var(--instrument-regular-secondary-color)"
            crosshairEnabled
            rotType={RotType.dots}
            rotPosition={RotPosition.innerCircle}
            rateOfTurnDegreesPerMinute={rateOfTurn}
            rotDotAnimationFactor={12}
            padding={60}
          />
        </div>
        <figcaption style={CAPTION}>Apparent wind: needle, target band and setpoint, no-go zone, true wind</figcaption>
      </figure>

      <figure style={FIGURE}>
        <div style={{ width: 190, height: 190 }}>
          <Watch
            watchCircleType={WatchCircleType.double}
            rotation={-heading}
            northArrow
            northMarker
            showLabels
            tickmarksInside
            insideLabelsFlush
            crosshairEnabled
            crosshairCenterCutout
            angleSetpoint={315}
            newAngleSetpoint={adjusting ? 320 : undefined}
            touching={adjusting}
            animateSetpoint
            windKnots={windKnots}
            windFromDirectionDeg={(heading + trueWindAngle) % 360}
            windSymbolRadius={OUTER_RING_RADIUS + 30}
            current={2}
            currentFromDirectionDeg={(heading + 200) % 360}
            currentSymbolRadius={OUTER_RING_RADIUS + 8}
            rotType={RotType.bar}
            rotPosition={RotPosition.scale}
            rotStartAngle={0}
            rotEndAngle={rateOfTurn / 2}
            rotPortStarboard
            rotAtZeroDeadband={1}
            rotPriority={Priority.enhanced}
            rateOfTurnDegreesPerMinute={rateOfTurn}
          />
        </div>
        <figcaption style={CAPTION}>Compass rose, heading up, with the course being adjusted</figcaption>
      </figure>

      <figure style={FIGURE}>
        <div style={{ width: 220, height: 130 }}>
          <Watch
            watchCircleType={WatchCircleType.doubleThin}
            areas={CLOSE_HAULED_ARC}
            zoomToFitArc
            tickFadeAngle={10}
            tickmarks={ARC_TICKMARKS}
            endLabelsMaxMin
            advices={adviceFor(windAngle)}
            needles={windNeedle}
            barAreas={[
              {
                startAngle: 0,
                endAngle: windAngle,
                fillColor: "var(--instrument-regular-tertiary-color)",
              },
            ]}
            angleSetpoint={TARGET_ANGLE}
            setpointOverride
          />
        </div>
        <figcaption style={CAPTION}>Close-hauled arc, zoomed, ticks fading at the ends</figcaption>
      </figure>

      <figure style={FIGURE}>
        <div style={{ width: 150, height: 90 }}>
          <Watch
            state={InstrumentState.off}
            northArrow
            northArrowInside
            clipBottom={45}
            clipTop={0}
            clipLeft={0}
            clipRight={0}
            angleSetpoint={TARGET_ANGLE}
            setpointOverride
          />
        </div>
        <figcaption style={CAPTION}>Saloon repeater, off, the top half only</figcaption>
      </figure>
    </div>
  );
}

The watch is the face under the radial instruments. It draws the rings, the tickmarks and labels, the visible arcs, bars, needles, advice zones, the setpoint marker, the vessel silhouettes, the rate-of-turn layer and the wind and current arrows, all from the angles it is given. The compass, the heading, the rudder, the speed gauge and the radial gauges are this face with a different set of layers. A screen uses those instruments and reaches for the watch only to build a new radial one. It ports obc-watch; the overview lists the other instruments.

Examples

Single circle

The face with its single ring and nothing drawn on it.
Code
import { Watch } from "@bosunkit/instruments";
import type { ReactElement } from "react";

export const description: string = "The face with its single ring and nothing drawn on it.";

export default function SingleCircle(): ReactElement {
  return (
    <div style={{ width: 200, height: 200 }}>
      <Watch />
    </div>
  );
}

Triple circle

The face with all three rings.
Code
import { Watch, WatchCircleType } from "@bosunkit/instruments";
import type { ReactElement } from "react";

export const description: string = "The face with all three rings.";

export default function TripleCircle(): ReactElement {
  return (
    <div style={{ width: 200, height: 200 }}>
      <Watch watchCircleType={WatchCircleType.triple} />
    </div>
  );
}

Building a radial instrument

Use a named instrument for a known quantity. Reach for Watch only when composing a reusable radial instrument with a defined angle convention, scale and readable value. Document how domain values map to angles, how the north or zero crossing behaves, and which layers share that reference.

Keep geometry in the instrument implementation and appearance in shared roles. Verify clipped arcs, external tick labels, different face sizes and missing data in all themes. A page-specific Watch composition should not become a second implementation of Compass, Rudder or GaugeRadial.

Props

PropTypeDefaultDescription
stateInstrumentState | undefinedInstrumentState.activeInstrument state (active, loading, off).
priorityPriority | undefinedPriority.regularColor priority (enhanced = blue palette, regular = gray palette).
watchCircleTypeWatchCircleType | undefinedWatchCircleType.singleRing layout: single (default), double, doubleThin (a thinner middle band) or triple (adds an inner band).
northArrowboolean | undefinedfalseDraws the north arrow on the outer ring. Default false.
northArrowInsideboolean | undefined–Draws the north arrow inside the outer ring instead of outside it. Defaults to the value of tickmarksInside.
northMarkerboolean | undefinedfalseReplace the north arrow with the compact heading-up north marker: a small triangle on the outer ring with an upright "N" at the outside label radius. Rotates with the card via rotation.
angleSetpointnumber | undefined–Setpoint angle in degrees (0° = 12 o'clock).
newAngleSetpointnumber | undefined–New setpoint being adjusted (focus mode). Renders a second, focus-state marker.
atAngleSetpointboolean | undefinedfalseWhether value matches setpoint (within deadband).
angleSetpointAtZeroDeadbandnumber | undefined0.5Deadband for setpoint zero detection (default 0.5°).
setpointOverrideboolean | undefinedfalseDerive setpoint color from priority regardless of loading/off state.
touchingboolean | undefinedfalseWhether the user is adjusting the setpoint; a lone setpoint marker is drawn in its focus state. Default false.
animateSetpointboolean | undefinedfalseAnimate setpoint confirm transitions: the original marker slides to the new position (shortest path via accumulated CSS-safe angles) while the departing new-setpoint marker fades out. Duration: var(--setpoint-animation-duration, 300ms).
paddingnumber | undefined–Space around the outer ring for tickmarks and labels, in SVG units. Default 24, or 60 when tickmarks outside the ring carry text.
areasWatchArea[] | undefined[]The visible arcs, each from startAngle to endAngle in degrees (0° = 12 o'clock, clockwise), with roundOutsideCut and roundInsideCut rounding their ends. Empty (default) shows the full circle.
barAreasWatchBarArea[] | undefined[]Filled arcs drawn in the ring band, each from startAngle to endAngle in degrees (0° = 12 o'clock, clockwise) in fillColor.
needlesWatchNeedle[] | undefined[]Short needles on the ring, each at angle in degrees (0° = 12 o'clock, clockwise) with a fillColor and a strokeColor.
tickmarksTickmark[] | undefined[]Tickmarks to draw, each at angle in degrees (0° = 12 o'clock, clockwise) with a type, optional label text and an optional color.
tickmarksInsideboolean | undefinedfalseDraws the tickmarks and their labels inside the ring instead of outside it. Default false.
tickmarkStyleTickmarkStyle | undefinedTickmarkStyle.regularTickmark color style. regular (default) draws every tick in the dim tertiary color; enhanced draws ticks in the brighter primary color and tertiary ticks in the secondary color.
advicesAngleAdviceRaw[] | undefined[]Advice and caution arcs, each from minAngle to maxAngle in degrees (0° = 12 o'clock, clockwise) with a type and a state of regular, hinted or triggered.
crosshairEnabledboolean | undefinedfalseDraws a crosshair through the center to the outer ring. Default false.
crosshairCenterCutoutboolean | undefinedfalseCuts the crosshair out inside the inner ring, so center content (e.g. center readouts) sits on a clean face. Requires crosshairEnabled.
showLabelsboolean | undefinedfalseShows the N, E, S and W letters around the ring. While true, tickmark text is not drawn. Default false.
insideLabelsFlushboolean | undefinedfalseWith tickmarksInside, anchor the NSEW label boxes flush against the inner ring (px-fixed) instead of the legacy gap that lets them drift toward the center as the instrument shrinks. Requires tickmarksInside.
vesselsWatchVessel[] | undefined[]Vessel silhouettes drawn at the center, each with a size, a CSS transform and a vesselImage.
windKnotsnumber | null | undefinednullWind speed in knots, snapped to the nearest barb icon. The wind arrow is drawn only when this and windFromDirectionDeg are set.
windFromDirectionDegnumber | null | undefinednullDirection the wind comes from, in degrees (0° = 12 o'clock on the face, clockwise).
windSymbolRadiusnumber | null | undefinednullDistance from the center to the tip of the wind arrow, in SVG units. Default: just outside the outer ring.
windColorstring | undefined–CSS color of the wind arrow. Default: the regular secondary instrument color.
currentnumber | null | undefinednullStrength of the current as a whole number of chevrons from 0 to 4; any other number draws the plain stem. The current arrow is drawn only when this and currentFromDirectionDeg are set.
currentFromDirectionDegnumber | null | undefinednullDirection the current comes from, in degrees (0° = 12 o'clock on the face, clockwise).
currentSymbolRadiusnumber | null | undefinednullDistance from the center to the base of the current arrow, in SVG units. Default: just outside the outer ring.
currentColorstring | undefined–CSS color of the current arrow. Default: the regular secondary instrument color.
starboardPortIndicatorboolean | undefinedfalseTints the right half of the ring in the starboard color and the left half in the port color. Default false.
clipTopnumber | undefined0Top clip, % of height. Ignored when zoomToFitArc is true.
clipBottomnumber | undefined0Bottom clip, % of height. Ignored when zoomToFitArc is true.
clipLeftnumber | undefined0Left clip, % of width — horizontal counterpart of clipTop/bottom (90° sectors).
clipRightnumber | undefined0Right clip, % of width — horizontal counterpart of clipTop/bottom (90° sectors).
endLabelsMaxMinboolean | undefinedfalsePlace the horizontal end labels (±90°, e.g. min/max) below the tick instead of beside it (the "Max-min" radial label placement).
scaleWindIconnumber | undefined1Scale factor for the wind arrow, applied about the center of the face, so it also moves the arrow radially. Default 1.
rotationnumber | undefined–Face rotation in degrees (heading-up/course-up modes).
zoomToFitArcboolean | undefinedfalseEnlarges the visible arc to fill the container instead of keeping the full circle's scale. Default false. Needs areas.
arcFrame(ZoomToFitArcFrame & { labelsHidden?: boolean }) | undefined–Pre-computed zoom-to-fit arc frame. When set, the watch skips its own computeZoomToFitArcFrame() call and uses these values directly. If you pass arcFrame, you own keeping it in sync with areas / watchCircleType — the watch will NOT recompute it. labelsHidden — set by svghelpers/radialFrame.ts's computeRadialFrame() when the container is too small to reserve label room — degrades tick label texts and the NSWE compass letters the same way upstream watch.ts's _labelsHidden does. Tick lines and the north arrow (with its own independent compact-triangle degradation at small scale) stay. Optional so frames without label awareness, e.g. a bare computeZoomToFitArcFrame() result, keep rendering labels as before.
tickFadeAnglenumber | undefined0Fades the tickmarks and labels out over this many degrees at each end of the first arc in areas, up to a quarter of the arc. Default 0 (no fade).
rotTypeRotType | undefined–ROT visualization: dots (spinning dots) or bar (arc bar). Undefined hides the ROT layer.
rotPositionRotPosition | undefinedRotPosition.innerCircleTrack for ROT elements: scale (outer ring) or innerCircle (default).
rotStartAnglenumber | undefined0Start angle of the ROT bar arc (0° = 12 o'clock, clockwise). Bar mode only.
rotEndAnglenumber | undefined0End angle of the ROT bar arc. The bar hides below the cap-width threshold.
rotPriorityPriority | undefined–Override priority for ROT color derivation (per-element priority).
rotPortStarboardboolean | undefinedfalseColors the rate-of-turn dots or bar by the direction of the turn: starboard colors for a positive rate, port colors for a negative one. Default false.
rotAtZeroDeadbandnumber | undefinedROT_ZERO_DEADBAND_DEGSmallest rate-of-turn bar, in degrees of arc, that is drawn as a bar; a shorter one is drawn as the zero marker. Default 0.05. Bar mode only.
rateOfTurnDegreesPerMinutenumber | undefined–Measured rate of turn in degrees per minute (the maritime/AIS convention, see ES-TRIN 2025/1 Art. 3.02 and ITU-R M.1371). Sign controls direction (positive = starboard/clockwise). Drives the dot animation (multiplied by rotDotAnimationFactor) and the port/starboard direction sign.
rotDotAnimationFactornumber | undefined18Visual amplification factor applied only to the spinning-dot animation (not to bar extent). Default 18 keeps the legacy visual feel (≈1 rpm at 20°/min).
classNamestring | undefined–Class names added to the instrument's root element.