Skip to content
design
Components

Pressure bar

How loaded the alert board is, with the active alerts by priority, the unacknowledged count and its wait.

On this page
A live board on the Ushant approach: alerts arrive until the bar turns overloaded, and acknowledging them one at a time, worst first, brings the load back down.
Code
import { Button } from "@bosunkit/components/button";
import { PressureBar, type PressureCounts } from "@bosunkit/components/pressure-bar";
import { Tooltip } from "@bosunkit/components/tooltip";
import {
  useEffect,
  useReducer,
  useState,
  type CSSProperties,
  type Dispatch,
  type ReactElement,
  type SetStateAction,
} from "react";

export const description: string =
  "A live board on the approach to the Ushant traffic separation scheme: alerts arrive until bosund calls the board " +
  "overwhelmed, and acknowledging them one at a time, worst first, brings the load back down.";

export const readsClock: boolean = true;

type Level = "alarm" | "warning" | "caution";

interface BoardAlert {
  readonly id: string;
  readonly title: string;
  readonly priority: Level;
  /** When the alert first fired, in epoch milliseconds. */
  readonly firedAt: number;
  /** When the crew acknowledged it, in epoch milliseconds; null while it waits. */
  readonly acknowledgedAt: number | null;
}

interface Board {
  readonly now: number;
  readonly alerts: readonly BoardAlert[];
  /** How many of `ARRIVALS` have arrived. */
  readonly arrived: number;
  /** When the board last went calm after every arrival, or null. */
  readonly calmSince: number | null;
}

/** A summary read aloud and the board it was read from; it shows until an alert arrives, is acknowledged or clears. */
interface Summary {
  readonly board: string;
  readonly text: string;
}

type BoardAction =
  | { readonly type: "tick"; readonly now: number }
  | { readonly type: "acknowledge"; readonly id: string; readonly now: number };

/** The alerts that arrive one after another as the vessel closes the lane. */
const ARRIVALS: readonly Omit<BoardAlert, "firedAt" | "acknowledgedAt">[] = [
  { id: "w1", title: "CPA 0.4 NM with MV Atlantic Star", priority: "warning" },
  { id: "c2", title: "Visibility below 2 NM", priority: "caution" },
  { id: "w2", title: "Crossing the northbound lane", priority: "warning" },
  { id: "a1", title: "CPA 0.1 NM with FV Morgane", priority: "alarm" },
  { id: "w3", title: "Wind above reef threshold", priority: "warning" },
  { id: "c3", title: "AIS target lost: Kerlouan", priority: "caution" },
  { id: "w4", title: "CPA 0.3 NM with MV Nordic Bay", priority: "warning" },
];

/** How often the example's clock moves. */
const TICK_MS: number = 500;
/** How often a new alert arrives. */
const ARRIVAL_MS: number = 2500;
/** How long an acknowledged alarm stays before its condition clears, as the target passes. */
const CLEAR_ALARM_MS: number = 4000;
/** How long the board stays calm before the approach starts again. */
const RESTART_MS: number = 5000;

const RANK: Readonly<Record<Level, number>> = { alarm: 3, warning: 2, caution: 1 };

const SUMMARY: CSSProperties = {
  margin: "var(--space-sm) 0 0",
  fontSize: "0.875em",
  color: "var(--text-dim)",
  minHeight: "1.5em",
};

function start(now: number): Board {
  return {
    now,
    alerts: [
      { id: "c1", title: "House bank 38 %", priority: "caution", firedAt: now - 2 * 60_000, acknowledgedAt: null },
    ],
    arrived: 0,
    calmSince: null,
  };
}

/**
 * The board's active alerts, as `Board.Active` keeps them: every unacknowledged alert, and an alarm until its
 * condition clears even after it is acknowledged (MSC.302: acknowledging silences it, it stays visible).
 */
function active(alerts: readonly BoardAlert[]): BoardAlert[] {
  return alerts.filter((alert: BoardAlert): boolean => alert.acknowledgedAt === null || alert.priority === "alarm");
}

/** Which alerts are on the board and which are acknowledged, so a summary read from another board is not shown. */
function boardState(alerts: readonly BoardAlert[]): string {
  return alerts
    .map((alert: BoardAlert): string => `${alert.id}:${alert.acknowledgedAt === null ? "" : "ack"}`)
    .join(" ");
}

/** The alert the crew work next: the highest priority unacknowledged one, then the one that has waited longest. */
function next(alerts: readonly BoardAlert[]): BoardAlert | undefined {
  return alerts
    .filter((alert: BoardAlert): boolean => alert.acknowledgedAt === null)
    .sort((first: BoardAlert, second: BoardAlert): number => {
      const byPriority: number = RANK[second.priority] - RANK[first.priority];
      return byPriority !== 0 ? byPriority : first.firedAt - second.firedAt;
    })
    .at(0);
}

/**
 * One step of the board: a new alert every 2.5 s until the lane is full, an acknowledged alarm cleared 4 s later as
 * its target passes, and a calm board starting the approach again after 5 s.
 */
function step(board: Board, action: BoardAction): Board {
  if (action.type === "acknowledge") {
    return {
      ...board,
      now: action.now,
      alerts: board.alerts.map((alert: BoardAlert): BoardAlert =>
        alert.id === action.id ? { ...alert, acknowledgedAt: action.now } : alert,
      ),
    };
  }
  const now: number = action.now;
  const alerts: BoardAlert[] = board.alerts.filter(
    (alert: BoardAlert): boolean =>
      !(alert.priority === "alarm" && alert.acknowledgedAt !== null && now - alert.acknowledgedAt >= CLEAR_ALARM_MS),
  );
  const arrival: Omit<BoardAlert, "firedAt" | "acknowledgedAt"> | undefined = ARRIVALS.at(board.arrived);
  const lastFired: number = Math.max(...alerts.map((alert: BoardAlert): number => alert.firedAt), 0);
  if (arrival !== undefined && now - lastFired >= ARRIVAL_MS) {
    return {
      now,
      alerts: [...alerts, { ...arrival, firedAt: now, acknowledgedAt: null }],
      arrived: board.arrived + 1,
      calmSince: null,
    };
  }
  if (arrival !== undefined || active(alerts).length > 0) {
    return { ...board, now, alerts, calmSince: null };
  }
  if (board.calmSince === null) {
    return { ...board, now, alerts, calmSince: now };
  }
  return now - board.calmSince >= RESTART_MS ? start(now) : { ...board, now, alerts };
}

export default function Showcase(): ReactElement {
  const [board, dispatch]: [Board, Dispatch<BoardAction>] = useReducer(step, undefined, (): Board => start(Date.now()));
  const [summary, setSummary]: [Summary | null, Dispatch<SetStateAction<Summary | null>>] = useState<Summary | null>(
    null,
  );

  useEffect((): (() => void) => {
    const timer: number = window.setInterval((): void => {
      dispatch({ type: "tick", now: Date.now() });
    }, TICK_MS);
    return (): void => {
      window.clearInterval(timer);
    };
  }, []);

  const shown: BoardAlert[] = active(board.alerts);
  const counts: PressureCounts = {
    alarm: shown.filter((alert: BoardAlert): boolean => alert.priority === "alarm").length,
    warning: shown.filter((alert: BoardAlert): boolean => alert.priority === "warning").length,
    caution: shown.filter((alert: BoardAlert): boolean => alert.priority === "caution").length,
  };
  const waiting: BoardAlert[] = board.alerts.filter((alert: BoardAlert): boolean => alert.acknowledgedAt === null);
  const oldest: number | undefined =
    waiting.length === 0 ? undefined : Math.min(...waiting.map((alert: BoardAlert): number => alert.firedAt));
  // bosund's rule (Board.Pressure): three or more alarms and warnings, or any alert unacknowledged for 5 minutes.
  const overwhelmed: boolean =
    counts.alarm + counts.warning >= 3 || (oldest !== undefined && board.now - oldest >= 5 * 60_000);
  const worst: BoardAlert | undefined = next(board.alerts);
  const state: string = boardState(board.alerts);

  // The bar cuts a long label with an ellipsis; the button's name and its tooltip keep the alert's full name.
  const acknowledgeLabel: string = worst === undefined ? "Nothing to acknowledge" : `Acknowledge ${worst.title}`;

  function readSummary(): void {
    const first: BoardAlert | undefined = worst ?? shown.at(0);
    setSummary({
      board: state,
      text:
        first === undefined
          ? "Nothing on the board."
          : `${String(shown.length)} ${shown.length === 1 ? "situation" : "situations"}. ${first.title}, most urgent.`,
    });
  }

  return (
    <div style={{ width: "100%" }}>
      <PressureBar
        counts={counts}
        unacknowledgedCount={waiting.length}
        oldestUnacknowledgedAt={oldest}
        overwhelmed={overwhelmed}
        actions={
          <>
            <Tooltip content={acknowledgeLabel}>
              <Button
                variant="primary"
                aria-label={acknowledgeLabel}
                disabled={worst === undefined}
                onClick={(): void => {
                  if (worst !== undefined) {
                    dispatch({ type: "acknowledge", id: worst.id, now: Date.now() });
                  }
                }}
              >
                <span>{acknowledgeLabel}</span>
              </Button>
            </Tooltip>
            <Button variant="ghost" onClick={readSummary}>
              Read summary
            </Button>
          </>
        }
      />
      <p style={SUMMARY} aria-live="polite">
        {summary?.board === state ? summary.text : ""}
      </p>
    </div>
  );
}

A pressure bar tells the watch how loaded the alert board is before they read a single card. It leads with the number of alerts waiting for an acknowledgement and how long the oldest of them has waited, then counts the active alerts at each priority, each with its IEC 62923 shape, its number and its word. Under them a load bar fills in proportion to the active alerts against a capacity, ten by default, in the priority colors. When bosund calls the board overwhelmed, the frame changes and the bar says what to do. The counts and the overload sit in a status region, so a screen reader announces a change. The actions sit apart in a named group at the end of the summary's row, at most half the bar wide, and under the load bar on a narrow bar; a long label is cut with an ellipsis and the button keeps the full name.

The bar changes form at its own width, not the screen's: wider than 40em (640 px at 16 px) the summary and the actions share a row; at 40em or less they stack. The hero gives the bar the frame's whole width, so it opens in the wide form, and its handle narrows it past the switch, with the width it leaves shown above the frame. The examples below sit in padded frames 40 px narrower than the hero's, so in a docs column near 680 px they are already under the switch.

01Gray while the board is quietThe bar is a plain surface with neutral text. A priority takes its color only when it has an alert, so a gray bar reads as a quiet board at a glance.
02Shape, number and word on every countEach count draws the priority shape from the shared mapping and its word beside the number, so a count reads the same in Night, in sunlight and to a color-blind eye. The load bar repeats the counts and is hidden from assistive technology.
03The wait comes firstAn alert left unacknowledged is the load that grows. The unacknowledged count and the age of the oldest lead the bar in the largest figures, and the age updates every 30 seconds.

Why it looks like this

Counts by priority, not chips. IMO MSC.302(87) asks the central alert display to keep alerts of different priorities clearly distinguishable (§9.6), to group them by priority (§9.16) and to show plainly that more alerts wait than one view holds (§9.12). OpenBridge's alert counter draws the count of each priority beside its alert icon, most urgent first, and names the row with the counts in words. The bar does the same as a row of figures on a plain surface, so a count is read as a quantity and not as a label or a button.

Color only for the abnormal. ISA-101 high-performance HMIs keep the display gray and spend color on abnormal states, so a colored element is always news. A zero count is drawn in the neutral color with its shape, and the priority color appears only on a priority that has alerts.

A load bar against a capacity. A bullet graph sets one measure against a reference so it reads at a glance without a gauge. Here the measure is the number of active alerts and the reference is capacity. EEMUA 191 asks a control room to keep its standing alarms below ten, so ten fills the bar; past it the bar stays full and each priority keeps its share.

Overloaded is a state of the whole bar. ISA-18.2 and EEMUA 191 treat an alarm flood, more than ten alarms in ten minutes for one operator, as a condition of its own that changes how the operator works. bosund's overwhelmed is a different measure: three or more alarms and warnings active at once, or any alert unacknowledged for five minutes. The bar shows it in words with the action to take, a warning-colored frame and an icon that is not a priority shape. It never pulses, because only an unacknowledged alarm or warning flashes on the bridge.

One acknowledgement per alert. MSC.302 §9.9 allows alarms and warnings to be acknowledged only one at a time, so the bar offers no control that acknowledges all of them. An acknowledge action names the alert it acknowledges; silencing every audible signal at once is allowed (§9.10) and belongs to the status strip.

Sources: IMO MSC.302(87), OpenBridge alert counter, ISA-101 high-performance HMI, bullet graph specification, alarm floods under ISA-18.2 and EEMUA 191 performance levels.

Examples

Calm

No active alerts

  • 0 alarms
  • 0 warnings
  • 0 cautions
No active alert: the bar says so in plain text, every count is zero in the neutral color and the load bar is empty.
Code
import { PressureBar } from "@bosunkit/components/pressure-bar";
import type { ReactElement } from "react";

export const description: string =
  "No active alert: the bar says so in plain text, every count is zero in the neutral color and the load bar is empty.";

export default function Calm(): ReactElement {
  return <PressureBar counts={{ alarm: 0, warning: 0, caution: 0 }} unacknowledgedCount={0} />;
}

Busy

Five active alerts, four of them unacknowledged for up to three minutes; the alarm is acknowledged and stays active until its condition clears. One alarm and one warning is under bosund's overload threshold of three, and the load bar fills half its capacity.
Code
import { PressureBar } from "@bosunkit/components/pressure-bar";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Five active alerts, four of them unacknowledged for up to three minutes; the alarm is acknowledged and stays " +
  "active until its condition clears. One alarm and one warning is under bosund's overload threshold of three, and " +
  "the load bar fills half its capacity.";

export const readsClock: boolean = true;

export default function Busy(): ReactElement {
  const [oldest]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now() - 3 * 60_000);
  return (
    <PressureBar
      counts={{ alarm: 1, warning: 1, caution: 3 }}
      unacknowledgedCount={4}
      oldestUnacknowledgedAt={oldest}
    />
  );
}

Overwhelmed

A flood: thirteen active alerts, eleven unacknowledged and two alarms acknowledged but not cleared. Past the capacity of ten the load bar is full, and with nine alarms and warnings bosund calls the board overwhelmed.
Code
import { PressureBar } from "@bosunkit/components/pressure-bar";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A flood: thirteen active alerts, eleven unacknowledged and two alarms acknowledged but not cleared. Past the " +
  "capacity of ten the load bar is full, and with nine alarms and warnings bosund calls the board overwhelmed.";

export const readsClock: boolean = true;

export default function Overwhelmed(): ReactElement {
  const [oldest]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now() - 4 * 60_000);
  return (
    <PressureBar
      counts={{ alarm: 3, warning: 6, caution: 4 }}
      unacknowledgedCount={11}
      oldestUnacknowledgedAt={oldest}
      overwhelmed
    />
  );
}

Stale unacknowledged

A light board of one warning and one caution, both unacknowledged, the oldest for 23 minutes: bosund calls a wait of five minutes or more overwhelmed, so the bar says so with only two alerts.
Code
import { PressureBar } from "@bosunkit/components/pressure-bar";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A light board of one warning and one caution, both unacknowledged, the oldest for 23 minutes: bosund calls a " +
  "wait of five minutes or more overwhelmed, so the bar says so with only two alerts.";

export const readsClock: boolean = true;

export default function StaleUnacknowledged(): ReactElement {
  const [oldest]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now() - 23 * 60_000);
  return (
    <PressureBar
      counts={{ alarm: 0, warning: 1, caution: 1 }}
      unacknowledgedCount={2}
      oldestUnacknowledgedAt={oldest}
      overwhelmed
    />
  );
}

On a narrow screen

A bar 40em wide or less stacks its counts, its load bar and its actions in one column, the actions at the right under the load bar. A button whose cut label would keep only a few letters beside the other actions takes a row of its own. The width is the bar's own, since the bar is its own size container, so a side panel on a wide screen gives the same form as a phone.

A bar 360 px wide: the counts, the load bar and the actions stack, the actions at the right under the load bar. Read summary writes what would be spoken under the bar.
Code
import { Button } from "@bosunkit/components/button";
import { PressureBar } from "@bosunkit/components/pressure-bar";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A bar 360 px wide: the counts, the load bar and the actions stack, the actions at the right under the load bar. " +
  "Read summary writes what would be spoken under the bar.";

export const readsClock: boolean = true;

const SPOKEN: CSSProperties = {
  margin: "var(--space-sm) 0 0",
  fontSize: "0.875em",
  color: "var(--text-dim)",
};

export default function OnANarrowScreen(): ReactElement {
  const [oldest]: [number, Dispatch<SetStateAction<number>>] = useState<number>((): number => Date.now() - 3 * 60_000);
  const [spoken, setSpoken]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
  return (
    <div style={{ width: "100%", maxWidth: 360 }}>
      <PressureBar
        counts={{ alarm: 1, warning: 1, caution: 3 }}
        unacknowledgedCount={4}
        oldestUnacknowledgedAt={oldest}
        actions={
          <Button
            onClick={(): void => {
              setSpoken("5 situations. CPA 0.1 NM with FV Morgane, most urgent.");
            }}
          >
            Read summary
          </Button>
        }
      />
      <p style={SPOKEN} aria-live="polite">
        {spoken}
      </p>
    </div>
  );
}

Props

PropTypeDefaultDescription
countsrequiredPressureCounts–The active alerts at each priority. Each is shown with its IEC 62923 shape, its number and its word.
unacknowledgedCountrequirednumber–How many of the active alerts wait for an acknowledgement.
oldestUnacknowledgedAtnumber | undefined–When the alert that has waited longest for an acknowledgement first fired, in epoch milliseconds. The bar shows how long it has waited, brought up to date every 30 seconds; unset, or with no alert waiting, it shows no wait.
overwhelmedboolean | undefinedfalsebosund's judgment that the board holds more than the watch can work through (pressure.overwhelmed). The whole bar changes and says what to do; nothing flashes.
capacitynumber | undefinedDEFAULT_CAPACITYThe number of active alerts at which the load bar is full; more fill it too. The default, 10, is the number of standing alarms EEMUA 191 asks a control room to stay below.
actionsReactNode–Buttons at the end of the summary's row, such as reading the summary aloud: kit Buttons, grouped and named for assistive technology, together at most half the bar wide. A label inside a span is cut with an ellipsis where it runs past that, so a button whose label names an alert gives the full name in aria-label. Never a button that acknowledges more than one alert (MSC.302 §9.9).
testIdstring | undefined–Becomes data-testid on the bar.
classNamestring | undefined–Classes added to the bar's own.
refRef<HTMLElement> | undefined–The bar's root element.
Do
Name the alert an acknowledgement acts on. The crew work the board one alert at a time, worst first.
Don't
Don't offer to acknowledge every alert at once. MSC.302 allows one acknowledgement per alarm or warning, and a flood is when an unread one is most likely.
  • Alert zone: the top bar's single worst alert and its ACK.
  • Alert card: the alerts the counts summarize, one card each.
  • Alarms and severity: the priorities, their shapes and their words.
  • Status strip: the silence control that acts on every audible signal.