Skip to content
design

Desktop

The nav station, with the app shell, the AIS target table, the weather, the alert load and the watch settings, composed from kit parts.

On this page
At the nav station on passage from Camaret to Audierne. The AIS table updates every second; about 25 seconds in, Galatea closes inside the CPA and TCPA limits and her row turns to an alarm, which the alert zone in the header acknowledges, or the helm does 50 seconds later. Change the CPA limit in the watch settings and the alarm fires at the new limit, or not at all.
Code
import { AlertList, type AlertListItem } from "@bosunkit/components/alert-list";
import { AlertZone, type AlertZoneAlert } from "@bosunkit/components/alert-zone";
import { AppShell } from "@bosunkit/components/app-shell";
import { Badge } from "@bosunkit/components/badge";
import { Card } from "@bosunkit/components/card";
import { DataTable, type Column } from "@bosunkit/components/data-table";
import { DescriptionList } from "@bosunkit/components/description-list";
import { HeaderBar } from "@bosunkit/components/header-bar";
import { NavList, type NavGroup } from "@bosunkit/components/nav-list";
import { PageHeader } from "@bosunkit/components/page-header";
import { formatPosition, type Position } from "@bosunkit/components/position-input";
import { PressureBar } from "@bosunkit/components/pressure-bar";
import { PRIORITY_SPECS, priorityBadgeVariant, type Priority } from "@bosunkit/components/priority";
import {
  SettingField,
  SettingSelect,
  SettingToggle,
  type SaveState,
  type SettingSelectOption,
} from "@bosunkit/components/setting-control";
import { StatusStrip, type SensorSummary } from "@bosunkit/components/status-strip";
import { TelemetryStrip, type TelemetryGroup } from "@bosunkit/components/telemetry-strip";
import { TrendChart, type TrendPoint } from "@bosunkit/components/trend-chart";
import { WeatherBar, type WeatherDay } from "@bosunkit/components/weather-bar";
import {
  Anchor,
  BookOpen,
  Cloud,
  Gauge,
  LayoutDashboard,
  ListChecks,
  Map as MapIcon,
  MessageSquare,
  Settings,
  Ship,
  TriangleAlert,
  Users,
  Waves,
  Wrench,
} from "lucide-react";
import {
  useEffect,
  useRef,
  useState,
  type Dispatch,
  type ReactElement,
  type ReactNode,
  type RefObject,
  type SetStateAction,
} from "react";

export const description: string =
  "The nav station on passage from Camaret to Audierne: the app shell with its navigation and resizable side panel, " +
  "the AIS target table " +
  "updating as Galatea closes to a close-quarters alarm that the alert zone acknowledges, the weather bar, the " +
  "alert load, the barometer's trend and the watch settings, whose CPA limit sets when the alarm fires.";

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 helm acknowledges the alarm this long after it fires. */
const HELM_ACKNOWLEDGES_AFTER: number = 50;
/** Chart pixels per nautical mile, the units the tracks are written in. */
const PIXELS_PER_NM: number = 150;
/** How long the example's save of a setting takes to come back. */
const SAVE_MS: number = 900;

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 {
  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;
}

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;
}

interface Limits {
  /** Close-quarters CPA, nautical miles. */
  readonly cpa: number;
  /** Close-quarters TCPA, minutes. */
  readonly tcpa: number;
}

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 },
  { mmsi: "235098765", name: "Blue Tern", kind: "Sailing", start: { x: 40, y: 40 }, course: 120, speed: 4.5 },
];

const SENSORS: readonly SensorSummary[] = ["gps", "compass", "depth", "wind", "speed", "ais"].map(
  (name: string): SensorSummary => ({ name, integrity: "fresh", ageSeconds: 1 }),
);

/** The pages, grouped as ui/web's navigation groups them; Situation counts the unacknowledged alarm while it waits. */
function navigationGroups(unacknowledged: boolean): readonly NavGroup[] {
  return [
    {
      id: "underway",
      label: "Underway",
      items: [
        { href: "#desktop-dashboard", label: "Dashboard", icon: <LayoutDashboard /> },
        {
          href: "#desktop-situation",
          label: "Situation",
          icon: <TriangleAlert />,
          ...(unacknowledged
            ? { count: { value: 1, label: "1 unacknowledged alert", priority: "alarm" as const } }
            : {}),
        },
        { href: "#desktop-conning", label: "Conning", icon: <Ship /> },
        {
          href: "#desktop-chart",
          label: "Chart",
          icon: <MapIcon />,
          count: { value: TARGETS.length, label: `${String(TARGETS.length)} targets tracked` },
        },
        { href: "#desktop-instruments", label: "Instruments", icon: <Gauge /> },
        { href: "#desktop-anchor", label: "Anchor", icon: <Anchor /> },
      ],
    },
    {
      id: "passage",
      label: "Passage",
      items: [
        { href: "#desktop-weather", label: "Weather", icon: <Cloud /> },
        { href: "#desktop-tides", label: "Tides", icon: <Waves /> },
        { href: "#desktop-log", label: "Log", icon: <BookOpen /> },
        { href: "#desktop-checklists", label: "Checklists", icon: <ListChecks /> },
      ],
    },
    {
      id: "aboard",
      label: "Aboard",
      items: [
        { href: "#desktop-chat", label: "Chat", icon: <MessageSquare /> },
        { href: "#desktop-crew", label: "Crew", icon: <Users />, count: { value: 4, label: "4 people aboard" } },
      ],
    },
    {
      id: "vessel",
      label: "Vessel",
      items: [
        { href: "#desktop-maintenance", label: "Maintenance", icon: <Wrench /> },
        { href: "#desktop-settings", label: "Settings", icon: <Settings /> },
      ],
    },
  ];
}

const CURRENT_PAGE: string = "Dashboard";

const DAYS: readonly WeatherDay[] = [
  { day: "Sat 11", weatherCode: 3, windMinKn: 15, windMaxKn: 22, windFromDeg: 290, gustMaxKn: 28, waveHeightM: 1.8 },
  { day: "Sun 12", weatherCode: 61, windMinKn: 18, windMaxKn: 26, windFromDeg: 230, gustMaxKn: 33, waveHeightM: 2.4 },
  { day: "Mon 13", weatherCode: 2, windMinKn: 10, windMaxKn: 16, windFromDeg: 300, gustMaxKn: 20, waveHeightM: 1.2 },
];

const PRIORITY_WORDS: Readonly<Record<Priority, string>> = {
  emergency: "Emergency",
  alarm: "Alarm",
  warning: "Warning",
  caution: "Caution",
  none: "No alert",
};

const NIGHT_OPTIONS: readonly SettingSelectOption[] = [
  { value: "sunset", label: "From sunset to sunrise" },
  { value: "always", label: "Always" },
  { value: "never", label: "Never" },
];

function radians(degrees: number): number {
  return (degrees * Math.PI) / 180;
}

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 };
}

/** 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 };
}

/** The alert a target raises: alarm inside the close-quarters limits, warning inside twice them, else none. */
function priorityOf(encounter: Encounter, limits: Limits): Priority {
  if (encounter.cpa === null || encounter.tcpa === null) {
    return "none";
  }
  if (encounter.cpa < limits.cpa && encounter.tcpa < limits.tcpa) {
    return "alarm";
  }
  return encounter.cpa < 2 * limits.cpa && encounter.tcpa < 2 * limits.tcpa ? "warning" : "none";
}

/** The second of the loop at which Galatea first raises the alarm under these limits, and the second she clears. */
function alarmWindow(limits: Limits): { 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 alarmed: boolean = priorityOf(encounterAt(GALATEA, second), limits) === "alarm";
    if (alarmed && fires === LOOP_SECONDS) {
      fires = second;
    }
    if (!alarmed && fires !== LOOP_SECONDS && clears === LOOP_SECONDS) {
      clears = second;
    }
  }
  return { fires, clears };
}

/** The barometer over the last six hours, a reading every ten minutes, falling 1 hPa an hour ahead of the front. */
function barometerSeries(now: number, feedSecond: number): readonly TrendPoint[] {
  return Array.from({ length: 37 }, (_: unknown, index: number): TrendPoint => {
    const hoursAgo: number = (36 - index) / 6;
    return { time: now - hoursAgo * 3_600_000, value: 1011.2 - (6 - hoursAgo) - feedSecond / 3600 };
  });
}

/** The chart's top-left corner, and the chart pixels to a minute of latitude (one nautical mile). */
const CHART_ORIGIN: Position = { latitude: 48.0717, longitude: -4.7667 };

/** A point on the chart as latitude and longitude: north up, 150 pixels to the nautical mile. */
function positionOnChart(point: Point): Position {
  const latitude: number = CHART_ORIGIN.latitude - point.y / PIXELS_PER_NM / 60;
  return {
    latitude,
    longitude: CHART_ORIGIN.longitude + point.x / PIXELS_PER_NM / 60 / Math.cos(radians(latitude)),
  };
}

function bearingText(degrees: number): string {
  return `${String(Math.round(degrees) % 360).padStart(3, "0")}°`;
}

function utcTime(epochMilliseconds: number): string {
  return new Date(epochMilliseconds).toISOString().slice(11, 16);
}

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;
}

interface Saved<Value> {
  readonly value: Value;
  readonly state: SaveState;
}

/** A setting whose save comes back from the vessel after SAVE_MS; the timer stops when the screen unmounts. */
function useSetting<Value>(initial: Value): [Saved<Value>, (next: Value) => void] {
  const [setting, setSetting]: [Saved<Value>, Dispatch<SetStateAction<Saved<Value>>>] = useState<Saved<Value>>({
    value: initial,
    state: "idle",
  });
  const timer: RefObject<ReturnType<typeof setTimeout> | undefined> = useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined,
  );
  useEffect((): (() => void) => {
    return (): void => {
      clearTimeout(timer.current);
    };
  }, []);

  function save(next: Value): void {
    setSetting({ value: next, state: "saving" });
    clearTimeout(timer.current);
    // Stands in for the PUT to the vessel's watch settings.
    timer.current = setTimeout((): void => {
      setSetting({ value: next, state: "saved" });
    }, SAVE_MS);
  }

  return [setting, save];
}

function positiveNumber(text: string, fallback: number): number {
  const value: number = Number.parseFloat(text);
  return value > 0 ? value : fallback;
}

/** A titled card whose contents stack with the spacing scale's small step between them. */
function Panel({ title, children }: { readonly title: string; readonly children: ReactNode }): ReactElement {
  return (
    <Card title={title}>
      <div className="desktop-screen__stack">{children}</div>
    </Card>
  );
}

const TRAFFIC_COLUMNS: readonly Column[] = [
  {
    key: "priority",
    label: "Alert",
    sortable: (left: Record<string, unknown>, right: Record<string, unknown>): number =>
      Number(left.rank) - Number(right.rank),
    render: (row: Record<string, unknown>): ReactNode => {
      const priority: Priority = row.priority as Priority;
      return priority === "none" ? (
        PRIORITY_WORDS.none
      ) : (
        <Badge variant={priorityBadgeVariant[priority]}>
          {PRIORITY_SPECS[priority].shape} {PRIORITY_WORDS[priority]}
        </Badge>
      );
    },
  },
  {
    key: "name",
    label: "Vessel",
    sortable: true,
    render: (row: Record<string, unknown>): ReactNode => `${String(row.name)} · ${String(row.kind)}`,
  },
  { key: "mmsi", label: "MMSI" },
  {
    key: "range",
    label: "Range",
    align: "end",
    sortable: true,
    render: (row: Record<string, unknown>): ReactNode => `${(row.range as number).toFixed(2)} NM`,
  },
  {
    key: "bearing",
    label: "Bearing",
    align: "end",
    sortable: true,
    render: (row: Record<string, unknown>): ReactNode => bearingText(row.bearing as number),
  },
  {
    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` : "–",
  },
  {
    key: "speed",
    label: "SOG",
    align: "end",
    sortable: true,
    render: (row: Record<string, unknown>): ReactNode => `${(row.speed as number).toFixed(1)} kn`,
  },
];

const PRIORITY_RANK: Readonly<Record<Priority, number>> = { emergency: 0, alarm: 1, warning: 2, caution: 3, none: 4 };

const SCREEN_STYLES: string = `
  .desktop-screen {
    overflow-x: auto;
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
  }
  .desktop-screen__width {
    width: max(100%, 80em);
    background: var(--bg);
    color: var(--text);
    font-family: var(--font-body);
  }
  .desktop-screen__page,
  .desktop-screen__side,
  .desktop-screen__stack {
    display: flex;
    flex-direction: column;
    min-width: 0;
  }
  .desktop-screen__page,
  .desktop-screen__side {
    gap: var(--space-md);
  }
  .desktop-screen__stack {
    gap: var(--space-sm);
  }
`;

export default function Showcase(): ReactElement {
  const seconds: number = useSeconds();
  const [openedAt]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now());
  const [alarmOn, saveAlarmOn]: [Saved<boolean>, (next: boolean) => void] = useSetting<boolean>(true);
  const [cpaLimit, saveCpaLimit]: [Saved<string>, (next: string) => void] = useSetting<string>("0.5");
  const [tcpaLimit, saveTcpaLimit]: [Saved<string>, (next: string) => void] = useSetting<string>("6");
  const [nightPalette, saveNightPalette]: [Saved<string>, (next: string) => void] = useSetting<string>("sunset");
  // 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 settleAcknowledgement: RefObject<(() => void) | null> = useRef<(() => void) | null>(null);

  const feedSecond: number = seconds + OPENS_AT_SECOND;
  const loop: number = Math.floor(feedSecond / LOOP_SECONDS);
  const loopSecond: number = feedSecond % LOOP_SECONDS;
  const limits: Limits = { cpa: positiveNumber(cpaLimit.value, 0.5), tcpa: positiveNumber(tcpaLimit.value, 6) };
  const alarmTimes: { readonly fires: number; readonly clears: number } = alarmWindow(limits);

  const alarmActive: boolean = alarmOn.value && loopSecond >= alarmTimes.fires && loopSecond < alarmTimes.clears;
  const requestedThisLoop: boolean = acknowledgeRequest !== null && acknowledgeRequest.loop === loop;
  const acknowledged: boolean =
    (requestedThisLoop && loopSecond > (acknowledgeRequest?.second ?? LOOP_SECONDS)) ||
    loopSecond >= alarmTimes.fires + HELM_ACKNOWLEDGES_AFTER;
  const unacknowledged: boolean = alarmActive && !acknowledged;
  const firedAt: number = openedAt + (loop * LOOP_SECONDS + alarmTimes.fires - OPENS_AT_SECOND) * 1000;

  // The acknowledgement settles when the board confirms it, so the alert row shows it pending until then.
  useEffect((): void => {
    if (acknowledged && settleAcknowledgement.current !== null) {
      settleAcknowledgement.current();
      settleAcknowledgement.current = null;
    }
  }, [acknowledged]);

  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 new Promise<void>((resolve: () => void): void => {
      settleAcknowledgement.current = resolve;
    });
  }

  const zoneAlert: AlertZoneAlert | null = unacknowledged
    ? { id: ALARM_ID, title: ALARM_TITLE, priority: "alarm", raisedAt: firedAt }
    : null;

  const rows: readonly Record<string, unknown>[] = TARGETS.map((target: Target): Record<string, unknown> => {
    const encounter: Encounter = encounterAt(target, loopSecond);
    const raised: Priority = priorityOf(encounter, limits);
    const priority: Priority = raised === "alarm" && !alarmOn.value ? "none" : raised;
    return {
      mmsi: target.mmsi,
      name: target.name,
      kind: target.kind,
      speed: target.speed,
      priority,
      rank: PRIORITY_RANK[priority],
      ...encounter,
    };
  });
  const distanceToWaypoint: number = Math.max(0.1, 4.8 - (seconds * OWN_TRACK.speed) / 3600);
  const ownPosition: Position = positionOnChart(positionAt(OWN_TRACK, loopSecond));
  const warnings: number = rows.filter((row: Record<string, unknown>): boolean => row.priority === "warning").length;

  const systems: readonly TelemetryGroup[] = [
    { label: "Engine", value: `${String(1850 + Math.round(20 * Math.sin(feedSecond / 6)))} rpm` },
    { label: "House", value: `${(12.8 + 0.05 * Math.sin(feedSecond / 17)).toFixed(1)} V` },
    { label: "Fuel", value: "64 %" },
    { label: "Water", value: "71 %" },
    { label: "AIS", value: `${String(TARGETS.length)} targets` },
  ];

  return (
    <div className="desktop-screen">
      <style href="bosunkit-composition-desktop" precedence="default">
        {SCREEN_STYLES}
      </style>
      <div className="desktop-screen__width">
        <AppShell
          stickyHeader={false}
          layoutStorageKey="bosun-design-composition-desktop"
          header={
            <HeaderBar
              title={CURRENT_PAGE}
              titleHeading={false}
              alertZone={
                <AlertZone
                  count={unacknowledged ? 1 : 0}
                  alert={zoneAlert}
                  acknowledgeAction={acknowledge}
                  boardHref="#desktop-traffic"
                />
              }
              statusStrip={
                <StatusStrip
                  connection="live"
                  lastEventAt={openedAt + seconds * 1000}
                  activity="underway"
                  sensors={SENSORS}
                  risk={alarmActive ? { level: "elevated", trend: "worsening" } : null}
                  doNotDisturb={{ enabled: false, missed: 0, onToggle: ignore }}
                  nightSuggested={false}
                  onDismissSuggestion={ignore}
                  unacknowledgedAlertIds={unacknowledged ? [ALARM_ID] : []}
                />
              }
            />
          }
          navigation={
            <NavList groups={navigationGroups(unacknowledged)} currentHref="#desktop-dashboard" headingLevel={3} />
          }
          main={
            <div className="desktop-screen__page">
              <PageHeader
                eyebrow="Underway"
                title={CURRENT_PAGE}
                subtitle={`Camaret to Audierne · ETA ${utcTime(openedAt + 2 * 3_600_000 + 40 * 60_000)} UTC`}
                contextBar={
                  <>
                    <Badge variant="accent">Leg 2 of 3</Badge>
                    <Badge variant="dim">Under sail</Badge>
                  </>
                }
              />
              <WeatherBar
                current={{
                  description: "Overcast",
                  wind: "18 kn, gusts 24 kn",
                  conditions: "backing southwest",
                  weatherCode: 3,
                  windFromDeg: 285,
                }}
                forecasts={[
                  { period: "+6H", wind: "18–24 kn", conditions: "rain" },
                  { period: "+12H", wind: "22–28 kn", conditions: "front passes" },
                ]}
                temperature="15 °C"
                pressure="1011 hPa"
                seaStateText="1.8 m"
                days={DAYS}
                sea={{ waveHeightM: 1.8, wavePeriodS: 8, waveFromDeg: 280, douglas: 4 }}
                pressureTrendHpa3h={-3.1}
                model={{ name: "ICON-EU", ageHours: 2, confidence: 0.85 }}
                limits={{ reefWindKn: 20, heavySeaM: 3, pressureDropHpa3h: 3 }}
              />
              <PressureBar
                counts={{ alarm: alarmActive ? 1 : 0, warning: 1 + warnings, caution: 1 }}
                unacknowledgedCount={unacknowledged ? 1 : 0}
                oldestUnacknowledgedAt={unacknowledged ? firedAt : undefined}
              />
              <div id="desktop-traffic">
                <Panel title="AIS targets">
                  <DataTable
                    columns={TRAFFIC_COLUMNS}
                    rows={rows}
                    rowKey={(row: Record<string, unknown>): string => String(row.mmsi)}
                    defaultSorting={[
                      { key: "priority", direction: "ascending" },
                      { key: "range", direction: "ascending" },
                    ]}
                    globalFilterable
                    defaultHiddenColumns={["mmsi"]}
                    columnPicker
                  />
                </Panel>
              </div>
              <Panel title="Barometer, last 6 hours">
                <TrendChart
                  data={barometerSeries(openedAt + seconds * 1000, feedSecond)}
                  height={160}
                  area
                  yTickCount={4}
                  thresholds={[{ value: 1005, color: PRIORITY_SPECS.warning.colorToken, label: "Gale front" }]}
                  formatValue={(value: number): string => `${value.toFixed(0)} hPa`}
                  formatTime={(time: number): string => `${utcTime(time)} UTC`}
                  label="Barometer, last 6 hours"
                />
              </Panel>
            </div>
          }
          aside={
            <div className="desktop-screen__side">
              <Panel title="Alerts">
                <AlertList
                  label="Active alerts"
                  alerts={[
                    {
                      id: "house",
                      priority: "caution",
                      title: "House bank at 52%",
                      description: "The engine is charging it; 60% in about 40 minutes.",
                      source: "Electrical",
                      time: utcTime(openedAt - 67 * 60_000),
                      timeReference: "UTC",
                      state: "acknowledged",
                      raisedAt: openedAt - 67 * 60_000,
                    },
                    {
                      id: "reef",
                      priority: "warning",
                      title: "True wind above the reefing limit",
                      description: "Gusts above 20 kn, the limit set for the first reef.",
                      source: "Wind",
                      time: utcTime(openedAt - 25 * 60_000),
                      timeReference: "UTC",
                      state: "acknowledged",
                      raisedAt: openedAt - 25 * 60_000,
                    },
                    ...(alarmActive
                      ? [
                          {
                            id: ALARM_ID,
                            priority: "alarm",
                            title: ALARM_TITLE,
                            description:
                              "Galatea will pass inside the CPA limit. Alter course to starboard, or call her on VHF 16.",
                            source: "AIS",
                            time: utcTime(firedAt),
                            timeReference: "UTC",
                            state: acknowledged ? "acknowledged" : "unacknowledged",
                            raisedAt: firedAt,
                            acknowledgeAction: acknowledge,
                          } satisfies AlertListItem,
                        ]
                      : []),
                  ]}
                />
              </Panel>
              <Panel title="Passage">
                <DescriptionList
                  items={[
                    { term: "Position", value: formatPosition(ownPosition) },
                    { term: "Next waypoint", value: "Raz de Sein N" },
                    { term: "Distance", value: `${distanceToWaypoint.toFixed(1)} NM` },
                    { term: "Fair stream from", value: `${utcTime(openedAt + 35 * 60_000)} UTC` },
                    { term: "ETA Audierne", value: `${utcTime(openedAt + 160 * 60_000)} UTC` },
                  ]}
                />
              </Panel>
              <Panel title="Watch settings">
                <SettingToggle
                  label="Close-quarters alarm"
                  description="Raise an alarm when an AIS target will pass inside the limits below."
                  checked={alarmOn.value}
                  state={alarmOn.state}
                  onCheckedChange={saveAlarmOn}
                />
                <SettingField
                  label="CPA limit"
                  description="The closest approach that raises the alarm."
                  value={cpaLimit.value}
                  state={cpaLimit.state}
                  onSave={saveCpaLimit}
                  type="number"
                  unit="NM"
                  maxDigits={3}
                />
                <SettingField
                  label="TCPA limit"
                  description="How far ahead the alarm looks."
                  value={tcpaLimit.value}
                  state={tcpaLimit.state}
                  onSave={saveTcpaLimit}
                  type="number"
                  unit="min"
                  maxDigits={2}
                />
                <SettingSelect
                  label="Night palette"
                  value={nightPalette.value}
                  options={NIGHT_OPTIONS}
                  state={nightPalette.state}
                  onSave={saveNightPalette}
                />
              </Panel>
            </div>
          }
          asideLabel="Alerts, passage and watch settings"
          footer={<TelemetryStrip groups={systems} />}
        />
      </div>
    </div>
  );
}

function ignore(): void {
  // The screen changes nothing on these taps.
}

The desktop screen is the app on the chart table's laptop or the saloon display: the full shell with the header bar, the navigation and a page, read sitting down and worked with a pointer. It mirrors ui/web's Layout.tsx (the header with the logo, the page title, the alert zone and the status strip; the sidebar; the page) and its dashboard. The AIS table reads the traffic event of the board stream, the weather bar the weather event, the alert load and the alert zone the board and board_meta events, and the watch settings save through the settings API.

01The shell frames every pageThe header bar with the alert zone and the status strip, and the navigation at the start, stay in place while the page changes, so an alert reaches the crew on every page.
02Dense, but sorted by riskThe nav station shows the most at once, so the target table sorts by alert first and range second: the target that matters is the first row, whatever else is listed.
03Settings beside what they changeThe CPA and TCPA limits sit next to the table they act on, and each saves with its own chip, so a changed limit is seen to take effect.

Built from

PartPageWhy it is there
AppShellApp shellThe frame: the header bar, the navigation, the page, the side panel and the footer, with the splitters that resize the navigation and the side panel and the widths kept in this browser.
HeaderBarHeader barThe logo, the page's name, the alert zone and the status strip in one bar; the page header below holds the page's heading.
LogoLogoThe small mark at the start of the header bar, which the header bar draws.
AlertZoneAlert zoneThe unacknowledged alarm, named, with its age and ACK, in the header bar of every page.
StatusStripStatus stripThe stream, the vessel underway, the sensors, the risk while the alarm is active, and the UTC clock.
NavListNavigation listThe pages in four groups, the current one marked, the alarm counted on Situation and the traffic on Chart; a panel that folds to the icon rail.
SplitterSplitterThe handles on the navigation's and the side panel's edges, which the app shell places.
PageHeaderPage headerThe page's name, the passage and its ETA, with the leg and the rig in its context bar.
BadgeBadgeThe leg and rig in the page header, and each target's priority shape and word in the table.
WeatherBarWeather barThe weather now, the pressure trend and the forecast days, marked against the reefing and sea limits.
PressureBarPressure barThe alert load by priority, the unacknowledged count and how long the oldest has waited.
CardCardThe titled panels: AIS targets, barometer, alerts, passage and watch settings.
AlertList, AlertListRowAlert list, Alert list rowThe active alerts in the side panel, ordered by priority and then newest first, each row with its shape, source, time and state, and an acknowledge button on the alarm's row.
DataTableData tableThe AIS targets, sorted by alert and range, with a search field and a Columns control.
PRIORITY_SPECS, priorityBadgeVariantAlarms and severityEach target's priority shape, word and badge color, and the threshold color on the barometer.
TrendChartTrend chartThe barometer over the last six hours, with the level that marks the gale front.
DescriptionListDescription listThe position, the next waypoint, the distance, the fair stream and the ETA.
formatPositionPosition inputThe position in chart notation.
SettingToggle, SettingField, SettingSelectSetting controlThe close-quarters alarm, its CPA and TCPA limits and the night palette, each with its save chip.
TelemetryStripTelemetry stripEngine, house bank, tanks and the AIS target count along the foot.

Layout

The screen is an app shell at least 80em wide, the width of the nav station's laptop, so it scrolls sideways in a narrower frame rather than taking a tablet's layout: the header bar, then the navigation panel, the page and the side panel side by side, and the telemetry strip along the foot. The shell is the screen's bk-screen, so the status strip and the alert zone keep their desktop forms at that width.

RegionWidthWhat the crew can change
Navigation14emDrag its splitter or step it with the arrow keys between 11em and 24em; Enter folds it to the 4.5em icon rail, where each page is named in a tooltip.
PageThe restGrows as the regions beside it narrow or fold.
Side panel20emDrag or step it between 14em and 36em; Enter shuts it and gives its room to the page.

The widths and folds stay in this browser under the screen's layoutStorageKey. The page stacks the page header, the weather bar, the alert load, the AIS table and the barometer with the spacing scale's middle step between them; the side panel stacks the alerts, the passage and the watch settings. This is the densest of the three screens: default size class and the table's full set of columns, because the nav station is read from a seat at arm's length rather than from the helm.

The same shell, narrower, makes the moves ui/web makes on the window: a rail on a tablet and a tab bar on a phone (see Mobile).

What the crew sees first

  1. The alert zone, in the header bar of every page, with the alarm's ▲ shape, title, age and ACK. ACK acknowledges that one alert and the zone clears when the board confirms it (MSC.302 §9.9, §9.15).
  2. The alert load, where the pressure bar counts the alarm under its ▲ shape and word and times how long it has waited unacknowledged.
  3. The alert list in the side panel, where the alarm's row comes first with its own acknowledge button, which shows Acknowledging until the board confirms, above the acknowledged warning and caution.
  4. The target's row, first in the table because the table sorts by alert: Galatea's badge reads ▲ Alarm while she is inside the limits, ● Warning while she is inside twice them.

Nothing on the screen is red except the alarm: the current page in the navigation takes a raised surface and the accent on its icon, and the threshold on the barometer is drawn in the warning color.

Theme and size

The screen takes the site's theme from the theme switch and sets no size class, so it renders at the default size. The night palette setting in the side panel is a setting the screen saves, not a switch for this page; the Conning screen shows the palette applied to a scope.

Choosing the traffic layout

This composition uses DataTable for sortable target columns. AIS target row is available for a dedicated traffic list with a ship-type icon and navigation details. Choose the table when comparison and sorting matter; choose rows when a compact target summary and opening its detail are the main task.

Do

All acknowledged

  • 1 alarm
  • 2 warnings
  • 1 caution
Show the alert load by priority, each count under its priority's shape and word.
Don't
4 alerts
Don't sum the load into one number. Four alerts says nothing about whether one of them is an alarm.

The watch settings sit beside the AIS table on the dashboard, where the watchkeeper sets the alarm limits against the traffic they cover, and on the Settings page as well.