Pressure bar
How loaded the alert board is, with the active alerts by priority, the unacknowledged count and its wait.
On this page
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.
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
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
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
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
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| countsrequired | PressureCounts | – | The active alerts at each priority. Each is shown with its IEC 62923 shape, its number and its word. |
| unacknowledgedCountrequired | number | – | How many of the active alerts wait for an acknowledgement. |
| oldestUnacknowledgedAt | number | 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. |
| overwhelmed | boolean | undefined | false | bosund'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. |
| capacity | number | undefined | DEFAULT_CAPACITY | The 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. |
| actions | ReactNode | – | 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). |
| testId | string | undefined | – | Becomes data-testid on the bar. |
| className | string | undefined | – | Classes added to the bar's own. |
| ref | Ref<HTMLElement> | undefined | – | The bar's root element. |
Related
- 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.