Conning
The helm screen at helm size, with the dials, the readings, the chart and the alert list, composed from kit parts.
On this page
Code
import { AlertCard } from "@bosunkit/components/alert-card";
import { AlertZone, type AlertZoneAlert } from "@bosunkit/components/alert-zone";
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { Card } from "@bosunkit/components/card";
import { AlertCardGrid } from "@bosunkit/components/alert-card-grid";
import { NauticalChart } from "@bosunkit/components/nautical-chart";
import {
AisTargetSymbol,
OwnVesselSymbol,
TrackSymbol,
type TrackPoint,
} from "@bosunkit/components/nautical-chart-symbols";
import { DataTable, type Column } from "@bosunkit/components/data-table";
import { DescriptionList } from "@bosunkit/components/description-list";
import { DialFrame } from "@bosunkit/components/dial-frame";
import { HeaderBar } from "@bosunkit/components/header-bar";
import { Label } from "@bosunkit/components/label";
import { PageHeader } from "@bosunkit/components/page-header";
import { Reading, type ReadingIntegrity, type ReadingTrend } from "@bosunkit/components/reading";
import { SectionDivider } from "@bosunkit/components/section-divider";
import { SegmentedRadioGroup, type SegmentedRadioOption } from "@bosunkit/components/segmented-radio-group";
import { Sparkline } from "@bosunkit/components/sparkline";
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { Switch } from "@bosunkit/components/switch";
import { TelemetryStrip, type TelemetryGroup } from "@bosunkit/components/telemetry-strip";
import {
Compass,
CompassDirection,
DepthActual,
Priority,
RateOfTurn,
ReadoutDirection,
Rudder,
SpeedGauge,
Wind,
WindVariant,
} from "@bosunkit/instruments";
import { PresentationScope } from "@bosunkit/provider";
import {
useEffect,
useId,
useState,
type Dispatch,
type ReactElement,
type ReactNode,
type SetStateAction,
} from "react";
import approach from "./approach.svg?raw";
export const description: string =
"The helm screen at helm size on passage from Camaret to Audierne: the dials, readings and chart follow a live " +
"feed, the wind backs and veers, the sailing vessel Galatea closes to a close-quarters alarm that is acknowledged " +
"and later clears, and a switch turns the screen to the night palette.";
export const readsClock: boolean = true;
/** The traffic picture repeats every twelve minutes; the screen opens five and a half minutes into it. */
const LOOP_SECONDS: number = 720;
const OPENS_AT_SECOND: number = 330;
/** If nobody presses ACK here, the cockpit repeater acknowledges the alarm this long after it fires. */
const REPEATER_ACKNOWLEDGES_AFTER: number = 40;
/** The close-quarters limit: a target that will pass inside 0.5 NM within the next 6 minutes is an alarm. */
const CPA_LIMIT_NM: number = 0.5;
const TCPA_LIMIT_MINUTES: number = 6;
/** NauticalChart pixels per nautical mile: the chart shows 2 NM from its center to its top and bottom edges. */
const PIXELS_PER_NM: number = 150;
const CHART_RANGE_NM: number = 2;
/** The vessel's draft in meters, for the depth dial. */
const DRAFT_M: number = 2.2;
/** The heading the autopilot steers; the rudder works against the yaw around it. */
const SET_HEADING: number = 196;
/** The engine's NMEA 2000 bus drops out for a minute each loop, from this second of it, so its panel goes stale. */
const ENGINE_BUS_DROPS_AT: number = 380;
const ENGINE_BUS_BACK_AT: number = 440;
/** How long the track behind own vessel is drawn, and a position on it every minute. */
const TRACK_SECONDS: number = 600;
const ALARM_ID: string = "close-quarters-galatea";
const ALARM_TITLE: string = "Close quarters with Galatea";
interface Point {
readonly x: number;
readonly y: number;
}
interface Track {
/** Where the vessel is at the start of the loop, in chart pixels. */
readonly start: Point;
/** Course over ground, degrees true. */
readonly course: number;
/** Speed over ground, knots. */
readonly speed: number;
}
interface Target extends Track {
readonly mmsi: string;
readonly name: string;
readonly kind: string;
}
const OWN_TRACK: Track = { start: { x: 400, y: 200 }, course: 200, speed: 6.4 };
/** The sailing vessel crossing from port, whose closest approach raises the alarm. */
const GALATEA: Target = {
mmsi: "244123456",
name: "Galatea",
kind: "Sailing",
start: { x: 130, y: 360 },
course: 80,
speed: 5.5,
};
const TARGETS: readonly Target[] = [
GALATEA,
{ mmsi: "227004321", name: "Iroise Express", kind: "Passenger", start: { x: 640, y: 560 }, course: 340, speed: 14 },
{ mmsi: "227118800", name: "Kerlaouen", kind: "Fishing", start: { x: 250, y: 110 }, course: 150, speed: 3 },
{ mmsi: "257220000", name: "Nordic Vega", kind: "Cargo", start: { x: 760, y: 90 }, course: 260, speed: 11 },
];
const SENSORS: readonly SensorSummary[] = ["gps", "compass", "depth", "wind", "speed", "ais"].map(
(name: string): SensorSummary => ({ name, integrity: "fresh", ageSeconds: 1 }),
);
function radians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
/** Where a vessel on a straight track is after the given seconds, in chart pixels (north up, y down). */
function positionAt(track: Track, seconds: number): Point {
const pixels: number = ((track.speed * PIXELS_PER_NM) / 3600) * seconds;
return {
x: track.start.x + Math.sin(radians(track.course)) * pixels,
y: track.start.y - Math.cos(radians(track.course)) * pixels,
};
}
function velocityOf(track: Track): Point {
const perSecond: number = (track.speed * PIXELS_PER_NM) / 3600;
return { x: Math.sin(radians(track.course)) * perSecond, y: -Math.cos(radians(track.course)) * perSecond };
}
interface Encounter {
/** Range now, nautical miles. */
readonly range: number;
/** Bearing from own vessel, degrees true. */
readonly bearing: number;
/** Closest point of approach, nautical miles; null when the target is opening. */
readonly cpa: number | null;
/** Minutes to the closest point of approach; null when the target is opening. */
readonly tcpa: number | null;
}
/** Range, bearing, CPA and TCPA of a target from own vessel at a second of the loop. */
function encounterAt(target: Target, loopSecond: number): Encounter {
const own: Point = positionAt(OWN_TRACK, loopSecond);
const other: Point = positionAt(target, loopSecond);
const relative: Point = { x: other.x - own.x, y: other.y - own.y };
const ownVelocity: Point = velocityOf(OWN_TRACK);
const otherVelocity: Point = velocityOf(target);
const closing: Point = { x: otherVelocity.x - ownVelocity.x, y: otherVelocity.y - ownVelocity.y };
const range: number = Math.hypot(relative.x, relative.y) / PIXELS_PER_NM;
const bearing: number = ((((Math.atan2(relative.x, -relative.y) * 180) / Math.PI) % 360) + 360) % 360;
const speedSquared: number = closing.x * closing.x + closing.y * closing.y;
const secondsToClosest: number = -(relative.x * closing.x + relative.y * closing.y) / speedSquared;
if (!(secondsToClosest > 0)) {
return { range, bearing, cpa: null, tcpa: null };
}
const cpa: number =
Math.hypot(relative.x + closing.x * secondsToClosest, relative.y + closing.y * secondsToClosest) / PIXELS_PER_NM;
return { range, bearing, cpa, tcpa: secondsToClosest / 60 };
}
function isCloseQuarters(encounter: Encounter): boolean {
return (
encounter.cpa !== null &&
encounter.tcpa !== null &&
encounter.cpa < CPA_LIMIT_NM &&
encounter.tcpa < TCPA_LIMIT_MINUTES
);
}
/** The second of the loop at which Galatea first meets the close-quarters limit, and the second she opens. */
function alarmWindow(): { readonly fires: number; readonly clears: number } {
let fires: number = LOOP_SECONDS;
let clears: number = LOOP_SECONDS;
for (let second: number = 0; second < LOOP_SECONDS; second++) {
const closeQuarters: boolean = isCloseQuarters(encounterAt(GALATEA, second));
if (closeQuarters && fires === LOOP_SECONDS) {
fires = second;
}
if (!closeQuarters && fires !== LOOP_SECONDS && clears === LOOP_SECONDS) {
clears = second;
}
}
return { fires, clears };
}
const ALARM_WINDOW: { readonly fires: number; readonly clears: number } = alarmWindow();
/** The heading the compass reports: a slow yaw around the set heading with a faster one on top. */
function headingAt(second: number): number {
return SET_HEADING + 5 * Math.sin(second / 23) + 1.4 * Math.sin(second / 4);
}
function courseAt(second: number): number {
return headingAt(second) + 4 + Math.sin(second / 7);
}
function speedOverGroundAt(second: number): number {
return OWN_TRACK.speed + 0.4 * Math.sin(second / 11) + 0.12 * Math.sin(second / 2.3);
}
function speedThroughWaterAt(second: number): number {
return speedOverGroundAt(second) - 0.7 + 0.08 * Math.sin(second / 5);
}
/** True wind backs to 276° and veers to 308° every three minutes, with a gust every thirteen seconds. */
function windFromAt(second: number): number {
return 292 + 16 * Math.sin((2 * Math.PI * second) / 180);
}
function windSpeedAt(second: number): number {
return 17 + 3 * Math.sin(second / 9) + (second % 13 < 2 ? 3 : 0);
}
/** Rate of turn, degrees per minute, from the heading a second apart. */
function rateOfTurnAt(second: number): number {
return (headingAt(second + 0.5) - headingAt(second - 0.5)) * 60;
}
function rudderAt(second: number): number {
return Math.max(-20, Math.min(20, -2.6 * (headingAt(second) - SET_HEADING)));
}
/** The sounder over the bank south of the headland: 18 to 36 m and back every two and a half minutes. */
function depthAt(second: number): number {
return 27 + 9 * Math.cos((2 * Math.PI * second) / 150) + 0.6 * Math.sin(second / 3);
}
/** A depth never overstates the water: tenths below 10 m and whole meters above, both rounded down. */
function flooredDepth(depth: number): number {
return depth < 10 ? Math.floor(depth * 10) / 10 : Math.floor(depth);
}
function trendOf(earlier: number, now: number, band: number): ReadingTrend {
if (now - earlier > band) {
return "rising";
}
return earlier - now > band ? "falling" : "steady";
}
/** The last minute of a series, a sample a second, oldest first. */
function lastMinute(series: (second: number) => number, second: number): readonly number[] {
return Array.from({ length: 60 }, (_: unknown, index: number): number => series(second - 59 + index));
}
/** A time of day as the crew reads it with its reference: `14:52`, printed with `UTC` beside it. */
function utcTime(epochMilliseconds: number): string {
return new Date(epochMilliseconds).toISOString().slice(11, 16);
}
function bearingText(degrees: number): string {
return `${String(Math.round(degrees) % 360).padStart(3, "0")}°`;
}
function useSeconds(): number {
const [seconds, setSeconds]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
useEffect((): (() => void) => {
const timer: ReturnType<typeof setInterval> = setInterval((): void => {
setSeconds((current: number): number => current + 1);
}, 1000);
return (): void => {
clearInterval(timer);
};
}, []);
return seconds;
}
/** A titled card whose contents stack with the spacing scale's small step between them. */
function Panel({
title,
integrity,
integrityAge,
children,
}: {
readonly title: string;
readonly integrity?: ReadingIntegrity;
readonly integrityAge?: string;
readonly children: ReactNode;
}): ReactElement {
return (
<Card title={title} integrity={integrity} integrityAge={integrityAge}>
<div className="conning-screen__stack">{children}</div>
</Card>
);
}
/** How long ago, as the crew reads it: seconds under a minute, whole minutes after. */
function ageText(seconds: number): string {
return seconds < 60 ? `${String(seconds)} s` : `${String(Math.floor(seconds / 60))} min`;
}
/** The rudder angle as a helm display writes it: degrees and the side, P for port and S for starboard. */
function rudderText(angle: number): string {
const degrees: number = Math.round(Math.abs(angle));
return degrees === 0 ? "0°" : `${String(degrees)}° ${angle < 0 ? "P" : "S"}`;
}
const TRAFFIC_COLUMNS: readonly Column[] = [
{
key: "name",
label: "Vessel",
render: (row: Record<string, unknown>): ReactNode => `${String(row.name)} · ${String(row.kind)}`,
},
{
key: "range",
label: "Range",
align: "end",
sortable: true,
render: (row: Record<string, unknown>): ReactNode => `${(row.range as number).toFixed(2)} NM`,
},
{
key: "cpa",
label: "CPA",
align: "end",
sortable: true,
render: (row: Record<string, unknown>): ReactNode =>
typeof row.cpa === "number" ? `${row.cpa.toFixed(2)} NM` : "Opening",
},
{
key: "tcpa",
label: "TCPA",
align: "end",
sortable: true,
render: (row: Record<string, unknown>): ReactNode =>
typeof row.tcpa === "number" ? `${String(Math.floor(row.tcpa))} min` : "–",
},
];
/** What the center column shows, as ui/web's conning screen switches it. */
const CENTER_VIEWS: readonly SegmentedRadioOption[] = [
{ value: "rose", label: "Rose" },
{ value: "chart", label: "NauticalChart" },
{ value: "both", label: "Both" },
];
const SCREEN_STYLES: string = `
.conning-screen {
container-type: inline-size;
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-md);
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.conning-screen__context {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm) var(--space-md);
}
.conning-screen__night {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.conning-screen__body {
display: grid;
gap: var(--space-md);
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "center" "alerts" "navigation" "systems" "traffic" "passage";
}
/* Until three columns fit, the side columns' panels are the grid's own items, placed one by one. */
.conning-screen__left,
.conning-screen__right {
display: contents;
}
.conning-screen__center { grid-area: center; }
.conning-screen__navigation { grid-area: navigation; }
.conning-screen__systems { grid-area: systems; }
.conning-screen__alerts { grid-area: alerts; }
.conning-screen__traffic { grid-area: traffic; }
.conning-screen__passage { grid-area: passage; }
/* Two columns: the instruments at the left, the alerts at the top of the right column with the navigation, the
systems, the traffic and the passage under them, so the alerts stay in view on a 768 px tall laptop. The last
row takes the instruments' extra height, so the right column's panels keep their own heights. */
@container (min-width: 44em) {
.conning-screen__body {
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
grid-template-rows: repeat(4, auto) 1fr;
grid-template-areas:
"center alerts"
"center navigation"
"center systems"
"center traffic"
"center passage";
}
}
/* Three columns where the side columns fit beside a center wide enough for the dials and the chart: the readings at
the left, the alerts heading the right. */
@container (min-width: 96em) {
.conning-screen__body {
grid-template-columns: minmax(14em, 17em) minmax(0, 1fr) minmax(16em, 20em);
grid-template-rows: none;
grid-template-areas: "left center right";
}
.conning-screen__left,
.conning-screen__right {
display: flex;
}
.conning-screen__left { grid-area: left; }
.conning-screen__right { grid-area: right; }
}
.conning-screen__left,
.conning-screen__center,
.conning-screen__right {
flex-direction: column;
gap: var(--space-md);
min-width: 0;
}
.conning-screen__center {
display: flex;
}
/* The alert list is as wide as its panel, so the panel is the screen its card grid measures. */
.conning-screen__alerts {
container: bk-screen / inline-size;
min-width: 0;
}
.conning-screen__stack {
display: flex;
flex-direction: column;
gap: var(--space-sm);
min-width: 0;
}
.conning-screen__view {
display: flex;
justify-content: center;
}
.conning-screen [hidden] {
display: none;
}
.conning-screen__dials {
display: grid;
gap: var(--space-sm);
grid-template-columns: repeat(auto-fit, minmax(8.5em, 1fr));
}
.conning-screen__trend {
display: grid;
gap: var(--space-xs);
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--border);
}
`;
export default function Showcase(): ReactElement {
const seconds: number = useSeconds();
const [openedAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
const [night, setNight]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const [centerView, setCenterView]: [string, Dispatch<SetStateAction<string>>] = useState<string>("both");
// The loop and second at which the crew pressed ACK here; bosund's next board snapshot confirms it.
const [acknowledgeRequest, setAcknowledgeRequest]: [
{ readonly loop: number; readonly second: number } | null,
Dispatch<SetStateAction<{ readonly loop: number; readonly second: number } | null>>,
] = useState<{ readonly loop: number; readonly second: number } | null>(null);
const nightSwitchId: string = useId();
const feedSecond: number = seconds + OPENS_AT_SECOND;
const loop: number = Math.floor(feedSecond / LOOP_SECONDS);
const loopSecond: number = feedSecond % LOOP_SECONDS;
const heading: number = headingAt(feedSecond);
const course: number = courseAt(feedSecond);
const speedOverGround: number = speedOverGroundAt(feedSecond);
const windFrom: number = windFromAt(feedSecond);
const windSpeed: number = windSpeedAt(feedSecond);
const depth: number = flooredDepth(depthAt(feedSecond));
const alarmActive: boolean = loopSecond >= ALARM_WINDOW.fires;
const alarmCleared: boolean = loopSecond >= ALARM_WINDOW.clears;
const requestedThisLoop: boolean = acknowledgeRequest !== null && acknowledgeRequest.loop === loop;
const acknowledgedHere: boolean = requestedThisLoop && loopSecond > (acknowledgeRequest?.second ?? LOOP_SECONDS);
const acknowledged: boolean = acknowledgedHere || loopSecond >= ALARM_WINDOW.fires + REPEATER_ACKNOWLEDGES_AFTER;
const acknowledging: boolean = requestedThisLoop && !acknowledged;
const unacknowledged: boolean = alarmActive && !alarmCleared && !acknowledged;
const firedAt: string = new Date(
openedAt + (loop * LOOP_SECONDS + ALARM_WINDOW.fires - OPENS_AT_SECOND) * 1000,
).toISOString();
const encounters: readonly Encounter[] = TARGETS.map((target: Target): Encounter => encounterAt(target, loopSecond));
const galatea: Encounter = encounterAt(GALATEA, loopSecond);
const ownPosition: Point = positionAt(OWN_TRACK, loopSecond);
const distanceToWaypoint: number = Math.max(0.1, 4.8 - (seconds * OWN_TRACK.speed) / 3600);
function acknowledge(): Promise<void> {
// Stands in for the POST to the board: the alert shows acknowledged once the next snapshot says so.
setAcknowledgeRequest({ loop, second: loopSecond });
return Promise.resolve();
}
const zoneAlert: AlertZoneAlert | null = unacknowledged
? { id: ALARM_ID, title: ALARM_TITLE, priority: "alarm", raisedAt: Date.parse(firedAt) }
: null;
const trafficRows: readonly Record<string, unknown>[] = TARGETS.map(
(target: Target, index: number): Record<string, unknown> => {
const encounter: Encounter = encounters[index] ?? galatea;
return { mmsi: target.mmsi, name: target.name, kind: target.kind, ...encounter };
},
);
// While the engine bus is down the panel keeps the last values it had and says how old they are.
const engineBusDown: boolean = loopSecond >= ENGINE_BUS_DROPS_AT && loopSecond < ENGINE_BUS_BACK_AT;
const engineSecond: number = engineBusDown ? feedSecond - (loopSecond - ENGINE_BUS_DROPS_AT) : feedSecond;
const systems: readonly TelemetryGroup[] = [
{ label: "Engine", value: `${String(1850 + Math.round(20 * Math.sin(engineSecond / 6)))} rpm` },
{ label: "Coolant", value: "82 °C" },
{ label: "House", value: `${(12.8 + 0.05 * Math.sin(engineSecond / 17)).toFixed(1)} V` },
{ label: "Fuel", value: "64 %" },
];
const ownTrack: readonly TrackPoint[] = Array.from(
{ length: TRACK_SECONDS / 60 + 1 },
(_: unknown, index: number): TrackPoint => {
const age: number = Math.min(loopSecond, TRACK_SECONDS - index * 60);
return { ...positionAt(OWN_TRACK, loopSecond - age), ageSeconds: age };
},
);
const galateaCard: ReactElement = (
<AlertCard
key={ALARM_ID}
priority="alarm"
title={ALARM_TITLE}
immediate={!alarmCleared}
acknowledged={acknowledged || alarmCleared}
cleared={alarmCleared}
time={utcTime(Date.parse(firedAt))}
timeReference="UTC"
message={
alarmCleared
? "Galatea has passed clear astern and is opening."
: "The sailing vessel Galatea is crossing from port and will pass inside the 0.5 NM limit. " +
"Alter course to starboard, or call her on VHF 16."
}
figures={[
{ label: "CPA", value: galatea.cpa === null ? "Opening" : `${galatea.cpa.toFixed(2)} NM` },
{ label: "TCPA", value: galatea.tcpa === null ? "–" : `${String(Math.floor(galatea.tcpa))} min` },
{ label: "Bearing", value: bearingText(galatea.bearing) },
]}
actions={
alarmCleared ? undefined : (
<Button
variant="primary"
size="lg"
disabled={acknowledged || acknowledging}
onClick={(): void => {
void acknowledge();
}}
>
{acknowledging ? "Acknowledging" : acknowledged ? "Acknowledged" : "Acknowledge"}
</Button>
)
}
/>
);
return (
<PresentationScope sizeClass="helm" theme={night ? "night" : undefined}>
<style href="bosunkit-composition-conning" precedence="default">
{SCREEN_STYLES}
</style>
<div className="conning-screen">
<HeaderBar
title="Conning"
titleHeading={false}
alertZone={
<AlertZone
count={unacknowledged ? 1 : 0}
alert={zoneAlert}
acknowledgeAction={acknowledge}
boardHref="#conning-alerts"
/>
}
statusStrip={
<StatusStrip
connection="live"
lastEventAt={openedAt + seconds * 1000}
activity="underway"
sensors={SENSORS}
risk={alarmActive && !alarmCleared ? { level: "elevated", trend: "worsening" } : null}
doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
nightSuggested={false}
onDismissSuggestion={ignore}
unacknowledgedAlertIds={unacknowledged ? [ALARM_ID] : []}
/>
}
/>
<PageHeader
eyebrow="Underway"
title="Conning"
subtitle="Camaret to Audierne"
contextBar={
<div className="conning-screen__context">
<Badge variant={unacknowledged ? "red" : "dim"} unacknowledged={unacknowledged}>
{unacknowledged ? "1 unacknowledged" : "Every alert acknowledged"}
</Badge>
<span className="conning-screen__night">
<Switch id={nightSwitchId} checked={night} onCheckedChange={setNight} />
<Label htmlFor={nightSwitchId}>Night palette</Label>
</span>
</div>
}
/>
<div className="conning-screen__body">
<div className="conning-screen__left">
<div className="conning-screen__navigation">
<Panel title="Navigation">
<Reading
direction={ReadoutDirection.horizontal}
label="HDG"
value={Math.round(heading)}
unit="°"
source="Compass"
maxDigits={3}
/>
<Reading
direction={ReadoutDirection.horizontal}
label="COG"
value={Math.round(course)}
unit="°"
source="GPS"
maxDigits={3}
/>
<div className="conning-screen__trend">
<Reading
direction={ReadoutDirection.horizontal}
label="SOG"
value={speedOverGround}
unit="kn"
source="GPS"
fractionDigits={1}
maxDigits={2}
trend={trendOf(speedOverGroundAt(feedSecond - 5), speedOverGround, 0.1)}
/>
<Sparkline
data={lastMinute(speedOverGroundAt, feedSecond)}
width="fill"
height={28}
area
unit="kn"
fractionDigits={1}
label="Speed over ground, last minute"
/>
</div>
<Reading
direction={ReadoutDirection.horizontal}
label="STW"
value={speedThroughWaterAt(feedSecond)}
unit="kn"
source="Log"
fractionDigits={1}
maxDigits={2}
/>
<div className="conning-screen__trend">
<Reading
direction={ReadoutDirection.horizontal}
label="Depth"
value={depth}
unit="m"
source="Sounder"
fractionDigits={depth < 10 ? 1 : 0}
maxDigits={2}
trend={trendOf(flooredDepth(depthAt(feedSecond - 5)), depth, 0.5)}
/>
<Sparkline
data={lastMinute((second: number): number => flooredDepth(depthAt(second)), feedSecond)}
width="fill"
height={28}
yMin={0}
area
formatValue={(value: number): string => `${String(flooredDepth(value))} m`}
label="Depth, last minute"
/>
</div>
</Panel>
</div>
<div className="conning-screen__systems">
<Panel
title="Systems"
integrity={engineBusDown ? "stale" : "fresh"}
integrityAge={engineBusDown ? ageText(loopSecond - ENGINE_BUS_DROPS_AT + 1) : undefined}
>
<TelemetryStrip groups={systems} />
</Panel>
</div>
</div>
<div className="conning-screen__center">
<div className="conning-screen__view">
<SegmentedRadioGroup
label="Center view"
options={CENTER_VIEWS}
value={centerView}
onValueChange={setCenterView}
/>
</div>
<div className="conning-screen__dials" hidden={centerView === "chart"}>
<DialFrame label="Heading" value={bearingText(heading)} source="Compass">
<Compass
heading={heading}
courseOverGround={course}
headingSetpoint={SET_HEADING}
currentWindSpeedKnots={windSpeed}
windFromDirection={windFrom}
rateOfTurnDegreesPerMinute={rateOfTurnAt(feedSecond)}
direction={CompassDirection.NorthUp}
priority={Priority.enhanced}
showLabels
/>
</DialFrame>
<DialFrame label="True wind" value={`${windSpeed.toFixed(0)} kn`} source="Masthead">
<Wind
variant={WindVariant.medium}
priority={Priority.enhanced}
currentWindSpeedKnots={windSpeed}
currentWindFromDirection={windFrom}
vesselHeadingDeg={heading}
/>
</DialFrame>
<DialFrame label="Speed" value={`${speedOverGround.toFixed(1)} kn`} source="GPS">
<SpeedGauge speed={speedOverGround} maxSpeed={10} tickmarkInterval={2} showLabels />
</DialFrame>
<DialFrame label="Rate of turn" value={`${rateOfTurnAt(feedSecond).toFixed(0)}°/min`} source="Compass">
<RateOfTurn
rateOfTurnDegreesPerMinute={rateOfTurnAt(feedSecond)}
hasReadout
label="ROT"
unit="°/min"
fractionDigits={0}
/>
</DialFrame>
<DialFrame label="Rudder" value={rudderText(rudderAt(feedSecond))} source="Autopilot">
<Rudder angle={rudderAt(feedSecond)} maxAngle={35} showLabels />
</DialFrame>
<DialFrame label="Depth" value={`${depth.toFixed(depth < 10 ? 1 : 0)} m`} source="Sounder">
<DepthActual depth={depth} draft={DRAFT_M} maxDepth={40} />
</DialFrame>
</div>
<div hidden={centerView === "rose"}>
<Panel title={`NauticalChart · ${String(CHART_RANGE_NM)} NM · North up`}>
<NauticalChart staticSvg={approach} width={800} height={600}>
<TrackSymbol points={ownTrack} maxAgeSeconds={TRACK_SECONDS} />
{TARGETS.map((target: Target, index: number): ReactElement => {
const at: Point = positionAt(target, loopSecond);
const encounter: Encounter = encounters[index] ?? galatea;
return (
<AisTargetSymbol
key={target.mmsi}
x={at.x}
y={at.y}
name={target.name}
cog={target.course}
sog={target.speed}
cpa={encounter.cpa}
tcpa={encounter.tcpa}
cpaLimit={CPA_LIMIT_NM}
tcpaLimit={TCPA_LIMIT_MINUTES}
pixelsPerNauticalMile={PIXELS_PER_NM}
/>
);
})}
<OwnVesselSymbol
x={ownPosition.x}
y={ownPosition.y}
heading={heading}
cog={course}
sog={speedOverGround}
pixelsPerNauticalMile={PIXELS_PER_NM}
/>
</NauticalChart>
</Panel>
</div>
</div>
<div className="conning-screen__right">
<div id="conning-alerts" className="conning-screen__alerts">
<Panel title="Alerts">
<AlertCardGrid>
{alarmActive && !alarmCleared ? (
<SectionDivider key="immediate" label="Immediate" variant="immediate" />
) : null}
{alarmActive && !alarmCleared ? galateaCard : null}
<SectionDivider key="monitoring" label="Monitoring" variant="monitoring" />
<AlertCard
key="reef"
priority="warning"
title="True wind above the reefing limit"
acknowledged
time={utcTime(openedAt - 25 * 60_000)}
timeReference="UTC"
message="True wind gusts above 20 kn, the limit set for the first reef. Reef before the Raz."
figures={[{ label: "True wind", value: `${windSpeed.toFixed(0)} kn` }]}
/>
<AlertCard
key="house"
priority="caution"
title="House bank at 52%"
acknowledged
time={utcTime(openedAt - 67 * 60_000)}
timeReference="UTC"
message="The engine is charging the house bank; it reaches 60% in about 40 minutes."
/>
{alarmCleared ? <SectionDivider key="cleared" label="Cleared" variant="cleared" /> : null}
{alarmCleared ? galateaCard : null}
</AlertCardGrid>
</Panel>
</div>
<div className="conning-screen__traffic">
<Panel title="Traffic">
<DataTable
columns={TRAFFIC_COLUMNS}
rows={trafficRows}
rowKey={(row: Record<string, unknown>): string => String(row.mmsi)}
defaultSorting={[{ key: "range", direction: "ascending" }]}
/>
</Panel>
</div>
<div className="conning-screen__passage">
<Panel title="Passage">
<DescriptionList
items={[
{ term: "Next waypoint", value: "Raz de Sein N" },
{ term: "Distance", value: `${distanceToWaypoint.toFixed(1)} NM` },
{ term: "Bearing", value: bearingText(SET_HEADING) },
{ term: "Fair stream from", value: `${utcTime(openedAt + 35 * 60_000)} UTC` },
{ term: "ETA Audierne", value: `${utcTime(openedAt + 160 * 60_000)} UTC` },
]}
/>
</Panel>
</div>
</div>
</div>
</div>
</PresentationScope>
);
}
function ignore(): void {
// The screen changes nothing on these taps.
}
The conning screen is what the helmsman reads at arm's length while steering: heading, wind, speed, rate of turn
and rudder on dials, the depth and the speed with their last minute, the chart with the traffic, and the alerts. It
mirrors ui/web's ConningPage (packages/bosun/ui/web/src/pages/ConningPage.tsx), which draws the same quantities
from the situation, traffic, board and board_meta events of the board stream: three columns of panels, the
navigation readings at the left, the dials and the chart in the center, and the traffic at the right.
Built from
| Part | Page | Why it is there |
|---|---|---|
PresentationScope | @bosunkit/provider | Puts the whole screen in the helm size class and, with the switch on, in the Night theme. |
HeaderBar | Header bar | The logo, the screen's name, the alert zone and the status strip in one bar, the zone on a row of its own when the screen is narrow. |
AlertZone | Alert zone | The unacknowledged alarm, named, with its age and ACK, first in the header bar. |
StatusStrip | Status strip | The stream's state, the vessel's activity, the sensors' integrity, the risk and the UTC clock. |
PageHeader | Page header | Names the screen and the passage; its context bar holds the alert count and the night switch. |
Badge | Badge | The count of unacknowledged alerts, red and flashing only while an alarm is unacknowledged. |
Switch, Label | Switch, Label | The night palette for this screen, as a labeled on/off control. |
SegmentedRadioGroup | Segmented radio group | The center view: the rose and the dials, the chart, or both, as ui/web's conning screen switches it. |
Reading | Reading | HDG, COG, SOG, STW and depth, each with its source and integrity. |
Sparkline | Sparkline | The last minute of speed over ground and depth, under their readings, as wide as the panel. |
TelemetryStrip | Telemetry strip | Engine revs, coolant, house voltage and fuel on one line. |
Card | Card | Each titled panel: navigation, systems, chart, alerts, traffic and passage; the systems panel marks its data stale while the engine's bus is down. |
DescriptionList | Description list | The passage's next waypoint, distance, bearing, fair stream and ETA as terms and values. |
DialFrame | Dial frame | Each dial's square face of one size, with its label, value and source under it. |
Compass | Compass | Heading, course over ground, the set heading, the wind and the rate-of-turn ring. |
Wind | Wind | True wind speed and direction against the vessel's heading. |
SpeedGauge | Speed gauge | Speed over ground on a 10 kn scale, its value in the dial frame's caption rather than a readout of its own. |
RateOfTurn | Rate of turn | The yaw the helmsman is correcting, in degrees per minute. |
Rudder | Rudder | The rudder angle the autopilot is holding. |
DepthActual | Depth | The water under the keel against the 2.2 m draft. |
Chart | Chart | The chart geometry, with own vessel, her track and the AIS targets drawn over it. |
OwnVesselSymbol, AisTargetSymbol, TrackSymbol | Chart symbols | Own vessel with her heading line and course vector, the last ten minutes of her track, and each target turned to its course, drawn dangerous inside the CPA and TCPA limits. |
AlertCardGrid, SectionDivider, AlertCard | Alert card grid, Section divider, Alert card | The alert list: Immediate, Monitoring and Cleared, each card with its priority, time and figures. |
Button | Button | Acknowledge on the alarm's card, at the large size. |
DataTable | Data table | The traffic: range, CPA and TCPA of each AIS target, nearest first. |
Layout
The screen is a column: the header bar, the page header, then a body grid of the instruments (the center view's
switch, the dials and the chart), the alerts, the navigation, the systems, the traffic and the passage. The grid
follows the width of the screen's frame through a container query, in em of the helm size, the way ui/web's
.conning-body-grid follows the window, and keeps the alerts in view at the top of a column wherever there is more
than one:
| Frame width | Columns | Order |
|---|---|---|
| 96em and wider | 14–17em, the rest, 16–20em | navigation and systems at the left, the instruments in the center, the alerts heading the traffic and the passage at the right |
| 44em to 96em | three fifths and two fifths | the instruments at the left; the alerts, then the navigation, the systems, the traffic and the passage at the right |
| under 44em | one column | the instruments, the alerts, the navigation, the systems, the traffic, the passage |
A laptop at 1,366 × 768 or 1,536 × 864 takes the two-column form, so the alert list is at the top of the right column beside the dials rather than under the chart; a 1,920 px display has room for the third column. The alerts panel is the screen its card grid measures, so the cards stand in one column as wide as the panel.
A segmented control heads the center column and chooses what it shows: the rose and the other dials, the chart, or
both, the choice sliding between its options. The dials sit in an auto-fit grid of at least 8.5em per dial, so six
dials fill one row on a wide helm display and wrap to two or three rows in a narrow frame. Each dial is in a dial
frame, whose square face the instrument fills, so every dial in a row has one face size and one top line. Panels and
gaps use the spacing scale (--space-sm, --space-md); the density is one step looser than the desktop screen,
through the composition's explicit spacing choices. The helm size class enlarges type and targets; fixed spacing
tokens do not acquire a density multiplier.
What the crew sees first
- The alert zone. An unacknowledged alarm shows in the header bar with its ▲ shape, count, title, age and ACK (MSC.302 §9.15). It flashes until acknowledged and leaves the zone once the board confirms the acknowledgement; the alert list keeps the card.
- The alarm's card. It heads the alert list in Immediate, with CPA, TCPA and bearing in its figures and Acknowledge as its one action. Acknowledge names one alert and shows Acknowledging until the next board snapshot confirms it; nothing acknowledges every alert at once (MSC.302 §9.9).
- The target on the chart. Galatea takes the larger dangerous target triangle in the alarm color, with "▲ Dangerous target" under her name, so the target that raised the alarm is found on the chart by shape and words as well as color.
- The dials and readings, which carry no alert color of their own while every sensor is fresh.
Theme and size
The screen sits in PresentationScope sizeClass="helm", which stamps data-size="helm" on the screen's element:
text sizes and readout heights take their helm values, and the em-based layout grows with them. The night switch
adds theme="night" to the same scope, so the screen turns to the night palette while the rest of the site keeps
its theme; the status strip's sensors and brightness menus copy the scope when they open. With the switch off,
the screen follows the site's theme switch.
Kit gaps
- Console grid. The three-rail grid and its collapse widths are the screen's own container queries.
- Passage ribbon and time window. ui/web's phase ribbon and its −2 h to +4 h trend strips have no kit counterpart, so the screen leaves them out.
- Traffic layout. This screen chooses a data table. The kit also supplies an AIS target row for compact traffic lists.
Related
- Alert zone: the header part that heads the screen and acknowledges one alert.
- Instruments: every dial on the screen, with its props.
- Data integrity: the stale and invalid marks a Reading draws.
- Mobile: the same parts at phone width.
The center view opens on both the rose and the chart, so the helm sees heading and position together.