Skip to content
design

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
On passage from Camaret to Audierne at helm size. The dials, readings and traffic follow a live feed and the wind backs and veers; about 25 seconds in, the sailing vessel Galatea closes inside 0.5 NM and raises a close-quarters alarm. Press ACK in the alert zone or Acknowledge on the card, or the cockpit repeater acknowledges it 40 seconds later; it clears when she opens. About 50 seconds in, the engine's bus drops out for a minute and the Systems panel reads stale with its age. The switch in the page header sets the night palette, and Rose, Chart and Both choose what the center shows.
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.

01Read from the helm seatThe whole screen sits in a helm size class, so text, readouts and targets grow for arm's length. Dials share one face size, so a glance across them lands at the same height.
02Alerts stay on topThe alert zone heads the screen and is never covered. The alert list below the chart groups the cards by what needs the crew now, and each card is acknowledged on its own.
03Every number has its trustThe navigation values go through Reading, so a stale or invalid sensor shows as such, and depth is rounded down so it never reads deeper than the water is.

Built from

PartPageWhy it is there
PresentationScope@bosunkit/providerPuts the whole screen in the helm size class and, with the switch on, in the Night theme.
HeaderBarHeader barThe 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.
AlertZoneAlert zoneThe unacknowledged alarm, named, with its age and ACK, first in the header bar.
StatusStripStatus stripThe stream's state, the vessel's activity, the sensors' integrity, the risk and the UTC clock.
PageHeaderPage headerNames the screen and the passage; its context bar holds the alert count and the night switch.
BadgeBadgeThe count of unacknowledged alerts, red and flashing only while an alarm is unacknowledged.
Switch, LabelSwitch, LabelThe night palette for this screen, as a labeled on/off control.
SegmentedRadioGroupSegmented radio groupThe center view: the rose and the dials, the chart, or both, as ui/web's conning screen switches it.
ReadingReadingHDG, COG, SOG, STW and depth, each with its source and integrity.
SparklineSparklineThe last minute of speed over ground and depth, under their readings, as wide as the panel.
TelemetryStripTelemetry stripEngine revs, coolant, house voltage and fuel on one line.
CardCardEach titled panel: navigation, systems, chart, alerts, traffic and passage; the systems panel marks its data stale while the engine's bus is down.
DescriptionListDescription listThe passage's next waypoint, distance, bearing, fair stream and ETA as terms and values.
DialFrameDial frameEach dial's square face of one size, with its label, value and source under it.
CompassCompassHeading, course over ground, the set heading, the wind and the rate-of-turn ring.
WindWindTrue wind speed and direction against the vessel's heading.
SpeedGaugeSpeed gaugeSpeed over ground on a 10 kn scale, its value in the dial frame's caption rather than a readout of its own.
RateOfTurnRate of turnThe yaw the helmsman is correcting, in degrees per minute.
RudderRudderThe rudder angle the autopilot is holding.
DepthActualDepthThe water under the keel against the 2.2 m draft.
ChartChartThe chart geometry, with own vessel, her track and the AIS targets drawn over it.
OwnVesselSymbol, AisTargetSymbol, TrackSymbolChart symbolsOwn 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, AlertCardAlert card grid, Section divider, Alert cardThe alert list: Immediate, Monitoring and Cleared, each card with its priority, time and figures.
ButtonButtonAcknowledge on the alarm's card, at the large size.
DataTableData tableThe 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 widthColumnsOrder
96em and wider14–17em, the rest, 16–20emnavigation and systems at the left, the instruments in the center, the alerts heading the traffic and the passage at the right
44em to 96emthree fifths and two fifthsthe instruments at the left; the alerts, then the navigation, the systems, the traffic and the passage at the right
under 44emone columnthe 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

  1. 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.
  2. 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).
  3. 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.
  4. 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.
Do
Pair a sparkline with the Reading it belongs to. The reading carries the value, its unit and its trust; the sparkline only its last minute.
Don't
Don't show a trend without its reading. A line with no value, unit or integrity cannot be steered by and cannot show a lost sounder.
  • 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.