Watch
The circular face that the radial instruments draw on, with rings, tickmarks, arcs, needles, setpoints and wind and current arrows.
On this page
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
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
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
| Prop | Type | Default | Description |
|---|---|---|---|
| state | InstrumentState | undefined | InstrumentState.active | Instrument state (active, loading, off). |
| priority | Priority | undefined | Priority.regular | Color priority (enhanced = blue palette, regular = gray palette). |
| watchCircleType | WatchCircleType | undefined | WatchCircleType.single | Ring layout: single (default), double, doubleThin (a thinner middle band) or triple (adds an inner band). |
| northArrow | boolean | undefined | false | Draws the north arrow on the outer ring. Default false. |
| northArrowInside | boolean | undefined | – | Draws the north arrow inside the outer ring instead of outside it. Defaults to the value of tickmarksInside. |
| northMarker | boolean | undefined | false | Replace 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. |
| angleSetpoint | number | undefined | – | Setpoint angle in degrees (0° = 12 o'clock). |
| newAngleSetpoint | number | undefined | – | New setpoint being adjusted (focus mode). Renders a second, focus-state marker. |
| atAngleSetpoint | boolean | undefined | false | Whether value matches setpoint (within deadband). |
| angleSetpointAtZeroDeadband | number | undefined | 0.5 | Deadband for setpoint zero detection (default 0.5°). |
| setpointOverride | boolean | undefined | false | Derive setpoint color from priority regardless of loading/off state. |
| touching | boolean | undefined | false | Whether the user is adjusting the setpoint; a lone setpoint marker is drawn in its focus state. Default false. |
| animateSetpoint | boolean | undefined | false | Animate 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). |
| padding | number | undefined | – | Space around the outer ring for tickmarks and labels, in SVG units. Default 24, or 60 when tickmarks outside
the ring carry text. |
| areas | WatchArea[] | 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. |
| barAreas | WatchBarArea[] | undefined | [] | Filled arcs drawn in the ring band, each from startAngle to endAngle in degrees (0° = 12 o'clock, clockwise) in
fillColor. |
| needles | WatchNeedle[] | undefined | [] | Short needles on the ring, each at angle in degrees (0° = 12 o'clock, clockwise) with a fillColor and a
strokeColor. |
| tickmarks | Tickmark[] | undefined | [] | Tickmarks to draw, each at angle in degrees (0° = 12 o'clock, clockwise) with a type, optional label text and
an optional color. |
| tickmarksInside | boolean | undefined | false | Draws the tickmarks and their labels inside the ring instead of outside it. Default false. |
| tickmarkStyle | TickmarkStyle | undefined | TickmarkStyle.regular | Tickmark 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. |
| advices | AngleAdviceRaw[] | 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. |
| crosshairEnabled | boolean | undefined | false | Draws a crosshair through the center to the outer ring. Default false. |
| crosshairCenterCutout | boolean | undefined | false | Cuts the crosshair out inside the inner ring, so center content (e.g.
center readouts) sits on a clean face. Requires crosshairEnabled. |
| showLabels | boolean | undefined | false | Shows the N, E, S and W letters around the ring. While true, tickmark text is not drawn. Default false. |
| insideLabelsFlush | boolean | undefined | false | With 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. |
| vessels | WatchVessel[] | undefined | [] | Vessel silhouettes drawn at the center, each with a size, a CSS transform and a vesselImage. |
| windKnots | number | null | undefined | null | Wind speed in knots, snapped to the nearest barb icon. The wind arrow is drawn only when this and
windFromDirectionDeg are set. |
| windFromDirectionDeg | number | null | undefined | null | Direction the wind comes from, in degrees (0° = 12 o'clock on the face, clockwise). |
| windSymbolRadius | number | null | undefined | null | Distance from the center to the tip of the wind arrow, in SVG units. Default: just outside the outer ring. |
| windColor | string | undefined | – | CSS color of the wind arrow. Default: the regular secondary instrument color. |
| current | number | null | undefined | null | Strength 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. |
| currentFromDirectionDeg | number | null | undefined | null | Direction the current comes from, in degrees (0° = 12 o'clock on the face, clockwise). |
| currentSymbolRadius | number | null | undefined | null | Distance from the center to the base of the current arrow, in SVG units. Default: just outside the outer ring. |
| currentColor | string | undefined | – | CSS color of the current arrow. Default: the regular secondary instrument color. |
| starboardPortIndicator | boolean | undefined | false | Tints the right half of the ring in the starboard color and the left half in the port color. Default false. |
| clipTop | number | undefined | 0 | Top clip, % of height. Ignored when zoomToFitArc is true. |
| clipBottom | number | undefined | 0 | Bottom clip, % of height. Ignored when zoomToFitArc is true. |
| clipLeft | number | undefined | 0 | Left clip, % of width — horizontal counterpart of clipTop/bottom (90° sectors). |
| clipRight | number | undefined | 0 | Right clip, % of width — horizontal counterpart of clipTop/bottom (90° sectors). |
| endLabelsMaxMin | boolean | undefined | false | Place the horizontal end labels (±90°, e.g. min/max) below the tick instead of beside it (the "Max-min" radial label placement). |
| scaleWindIcon | number | undefined | 1 | Scale factor for the wind arrow, applied about the center of the face, so it also moves the arrow radially. Default
1. |
| rotation | number | undefined | – | Face rotation in degrees (heading-up/course-up modes). |
| zoomToFitArc | boolean | undefined | false | Enlarges 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. |
| tickFadeAngle | number | undefined | 0 | Fades 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). |
| rotType | RotType | undefined | – | ROT visualization: dots (spinning dots) or bar (arc bar). Undefined hides the ROT layer. |
| rotPosition | RotPosition | undefined | RotPosition.innerCircle | Track for ROT elements: scale (outer ring) or innerCircle (default). |
| rotStartAngle | number | undefined | 0 | Start angle of the ROT bar arc (0° = 12 o'clock, clockwise). Bar mode only. |
| rotEndAngle | number | undefined | 0 | End angle of the ROT bar arc. The bar hides below the cap-width threshold. |
| rotPriority | Priority | undefined | – | Override priority for ROT color derivation (per-element priority). |
| rotPortStarboard | boolean | undefined | false | Colors 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. |
| rotAtZeroDeadband | number | undefined | ROT_ZERO_DEADBAND_DEG | Smallest 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. |
| rateOfTurnDegreesPerMinute | number | 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. |
| rotDotAnimationFactor | number | undefined | 18 | Visual 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). |
| className | string | undefined | – | Class names added to the instrument's root element. |
Related
- Compass: the watch with the heading layers.
- Instrument radial: the watch with a value arc.