Position input
A position in degrees and decimal minutes, each coordinate with its hemisphere, that reads pasted positions.
On this page
Code
import { formatPosition, PositionInput, type Position } from "@bosunkit/components/position-input";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A man-overboard position, required, filled from the vessel's fix with one press, typed as degrees and minutes " +
"with a hemisphere per coordinate, or pasted as decimal degrees or chart notation; beside it the anchor's " +
"position, read-only, and the value the field reports.";
const FIX: Position = { latitude: 50.1533, longitude: -5.0587 };
export default function Showcase(): ReactElement {
const [position, setPosition]: [Position | null, Dispatch<SetStateAction<Position | null>>] =
useState<Position | null>(null);
return (
<div style={{ width: "100%", maxWidth: 520, display: "grid", gap: "var(--space-lg)" }}>
<PositionInput
label="Man overboard at"
required
value={position}
onValueChange={setPosition}
vesselPosition={FIX}
hint="Paste 50.1533, -5.0587 or 50°09.198'N 005°03.522'W, or use the fix."
/>
<PositionInput label="Anchored at" readOnly value={{ latitude: 50.1561, longitude: -5.0703 }} />
<output style={{ fontSize: 12, color: "var(--text-dim)" }}>
The page receives: {position === null ? "no position yet" : formatPosition(position)}
</output>
</div>
);
}
A position input takes a position the way a chart writes it: degrees and decimal minutes for each coordinate, with
N or S and E or W. Each coordinate has a degrees input and a minutes input, so a phone's numeric keypad is enough,
and a hemisphere button that turns N into S and E into W. Typing a hemisphere letter into any of the inputs sets
that hemisphere, wherever it lands: N and S the latitude's, E and W the longitude's. Once two latitude digits or
three longitude digits are typed, the minutes take the next keys; once a latitude's minutes are whole, the
longitude takes them. A stream of digits flows through all four fields into a whole position, and a backspace on
an empty field walks back the same way, removing the previous field's last digit. A degrees field holds its
digits and no more, and a minutes field holds two whole digits and three decimals: digits past the two whole ones
start the decimals, so 12345 becomes 12.345.
Anything that is not a plain number, typed or pasted into any of its inputs, is read as a written position and spread over the inputs: a pair in decimal degrees, chart notation, degrees, minutes and seconds, with the hemisphere letters before or after. Text that reads as no position is dropped, so a letter or a mark a chart could not write never lands in a numeric field. When the crew leave the field, a coordinate that reads is written back in chart notation, as bosund's log writes it: latitude degrees in two digits, longitude in three, minutes that round to 60 carried into the degrees. The field writes three decimals of a minute, about 1.85 m.
The page receives the position in signed decimal degrees, or null while the inputs do not hold a whole, valid
one. The field refuses what bosund refuses: a latitude past 90°, a longitude past 180°, and 0° 0′, the position a
receiver reports without a fix. A button at the end copies the vessel's position, which the page passes in; the
kit fetches nothing.
Examples
Empty
Code
import { PositionInput } from "@bosunkit/components/position-input";
import type { ReactElement } from "react";
export const description: string =
"An empty position: degrees and minutes for each coordinate on a numeric keypad, and a hemisphere button that " +
"turns N into S and E into W.";
export default function Empty(): ReactElement {
return <PositionInput label="Waypoint" />;
}
Filled
Code
import { PositionInput } from "@bosunkit/components/position-input";
import type { ReactElement } from "react";
export const description: string =
"A held position in chart notation: latitude degrees in two digits, longitude in three, minutes to three decimals.";
export default function Filled(): ReactElement {
return <PositionInput label="Waypoint" defaultValue={{ latitude: 39.469, longitude: -0.3763 }} />;
}
The vessel's position
Code
import { PositionInput, type Position } from "@bosunkit/components/position-input";
import type { ReactElement } from "react";
export const description: string =
"The button at the end copies the vessel's position, which the page passes in; with no fix it is disabled and " +
"its name says why.";
const FIX: Position = { latitude: 50.1533, longitude: -5.0587 };
export default function VesselPosition(): ReactElement {
return (
<div style={{ width: "100%", display: "grid", gap: "var(--space-lg)" }}>
<PositionInput label="Mark" vesselPosition={FIX} />
<PositionInput label="Mark, no fix" vesselPosition={null} />
</div>
);
}
Written forms it reads
| 50.2057, -1.3909 | 50° 12.342′ N 001° 23.454′ W |
| 50,2057 -1,3909 | 50° 12.342′ N 001° 23.454′ W |
| 50°12.345'N 001°23.456'W | 50° 12.345′ N 001° 23.456′ W |
| 50 12.345 N, 001 23.456 W | 50° 12.345′ N 001° 23.456′ W |
| N50 12.345 W001 23.456 | 50° 12.345′ N 001° 23.456′ W |
| 50°12'20.7"N 1°23'27.4"W | 50° 12.345′ N 001° 23.457′ W |
Code
import { formatPosition, parsePosition, type Position } from "@bosunkit/components/position-input";
import type { ReactElement } from "react";
export const description: string =
"The written forms the field reads when they are pasted or typed into any of its inputs, each beside the position " +
"it becomes.";
const FORMS: readonly string[] = [
"50.2057, -1.3909",
"50,2057 -1,3909",
"50°12.345'N 001°23.456'W",
"50 12.345 N, 001 23.456 W",
"N50 12.345 W001 23.456",
"50°12'20.7\"N 1°23'27.4\"W",
];
export default function Formats(): ReactElement {
return (
<table style={{ fontFamily: "var(--font-mono)", fontSize: 13, borderCollapse: "collapse" }}>
<tbody>
{FORMS.map((form: string): ReactElement => {
const position: Position | null = parsePosition(form);
return (
<tr key={form}>
<td style={{ padding: "var(--space-xs) var(--space-md)", color: "var(--text-dim)" }}>{form}</td>
<td style={{ padding: "var(--space-xs) var(--space-md)" }}>
{position === null ? "not a position" : formatPosition(position)}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
Invalid
Code
import { PositionInput } from "@bosunkit/components/position-input";
import type { ReactElement } from "react";
export const description: string =
"0° 0′ is the position a receiver reports with no fix, which bosund refuses; the field says so as soon as it holds " +
"it. Out-of-range degrees and minutes of 60 or more are named once the crew leave the field.";
export default function Invalid(): ReactElement {
return <PositionInput label="Waypoint" defaultValue={{ latitude: 0, longitude: 0 }} />;
}
Read-only
Code
import { PositionInput } from "@bosunkit/components/position-input";
import type { ReactElement } from "react";
export const description: string =
"A read-only position keeps its hemisphere buttons still and offers no copy of the fix.";
export default function ReadOnly(): ReactElement {
return <PositionInput label="Anchored at" readOnly value={{ latitude: 50.1561, longitude: -5.0703 }} />;
}
Disabled
Code
import { PositionInput } from "@bosunkit/components/position-input";
import type { ReactElement } from "react";
export const description: string = "A disabled position is dimmed and takes no focus.";
export default function Disabled(): ReactElement {
return <PositionInput label="Last port" disabled defaultValue={{ latitude: 50.1533, longitude: -5.0587 }} />;
}
Inside a form field
Code
import { FormField } from "@bosunkit/components/form-field";
import { PositionInput } from "@bosunkit/components/position-input";
import type { ReactElement } from "react";
export const description: string = "Inside a form field, named by `aria-label`, with the form field's label above it.";
export default function InFormField(): ReactElement {
return (
<FormField label="Dive site">
<PositionInput aria-label="Dive site" defaultValue={{ latitude: 36.0103, longitude: 14.3292 }} />
</FormField>
);
}
Paste
The paste button comes first at the end of the field, before the vessel's position. It reads:
- chart notation, decimal degrees with signs or hemispheres, and degrees, minutes and seconds, in any of the forms above;
- two decimals written longitude first, when the first can only be a longitude (past 90°);
- a Google Maps link (a dropped pin or the map's view), an Apple or Google
q=orll=link, and an OpenStreetMap marker or view; - a
geo:URI; - an NMEA 0183 GGA or RMC sentence with a fix, read only where bosund's decoders trust it: a checksum that matches, a GGA fix of quality 1 to 5, and an RMC that is valid (status A, and no FAA mode or navigational status saying otherwise).
A what3words address names no position the field can compute, and the field says so. The paste button shows only when the clipboard holds something the field can take, named by what it would paste (Paste 50° 12.342′ N 001° 23.454′ W). To know that, the field reads the clipboard when it takes focus and when the window comes back, and only where the browser lets the page read it without asking: the clipboard-read permission is granted. Where the browser would ask first, or does not know the permission (Firefox, Safari), the field reads nothing and shows no button, so no prompt appears on focus; Ctrl+V and a long press on a phone paste through the same reader. A read-only or disabled field shows no paste button.
- https://www.google.com/maps/@50.2057,-1.3909,15z
- https://www.openstreetmap.org/#map=15/50.2057/-1.3909
- https://maps.apple.com/?ll=50.2057,-1.3909
- geo:50.2057,-1.3909
- $GNRMC,092725.00,A,4717.11399,N,00833.91590,E,0.004,77.52,160223,,,A*46
- -122.4194, 37.7749
- ///filled.count.soap
Code
import { PositionInput } from "@bosunkit/components/position-input";
import type { ReactElement } from "react";
export const description: string =
"Copy one of these lines, then focus the field: where the browser lets the page read the clipboard, a paste button named by the position appears before the vessel's position; Ctrl+V works everywhere. Map links, a geo URI, an NMEA sentence and a longitude-first pair all become a position; the what3words address names no position, so no button appears for it.";
const SAMPLES: readonly string[] = [
"https://www.google.com/maps/@50.2057,-1.3909,15z",
"https://www.openstreetmap.org/#map=15/50.2057/-1.3909",
"https://maps.apple.com/?ll=50.2057,-1.3909",
"geo:50.2057,-1.3909",
"$GNRMC,092725.00,A,4717.11399,N,00833.91590,E,0.004,77.52,160223,,,A*46",
"-122.4194, 37.7749",
"///filled.count.soap",
];
export default function Paste(): ReactElement {
return (
<div style={{ width: "100%", maxWidth: 520, display: "grid", gap: "var(--space-md)" }}>
<PositionInput label="Waypoint" vesselPosition={{ latitude: 50.1533, longitude: -5.0587 }} />
<ul style={{ margin: 0, paddingLeft: "var(--space-lg)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
{SAMPLES.map((sample: string): ReactElement => (
<li key={sample} style={{ userSelect: "all", overflowWrap: "anywhere" }}>
{sample}
</li>
))}
</ul>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | undefined | – | The field's name on the outline, translated by the caller; omit it inside a FormField. |
| value | Position | null | undefined | – | The position, or null while the fields do not hold a whole, valid one. |
| defaultValue | Position | null | undefined | – | The position it starts with when the caller does not hold value. |
| onValueChange | ((value: Position | null) => void) | undefined | – | Called with the position when both coordinates are whole and valid, and with null when they are not. |
| vesselPosition | Position | null | undefined | – | The vessel's position, for the button that copies it in: the button shows when this is set, and is disabled
while it is null (no fix). The kit fetches nothing; the caller passes the fix it has. |
| hint | string | undefined | – | Text under the field, translated by the caller. |
| error | string | undefined | – | The caller's own error, shown in place of the field's. |
| required | boolean | undefined | – | Whether the field must hold a position. |
| disabled | boolean | undefined | – | Whether the field takes no input. |
| readOnly | boolean | undefined | – | Whether the field shows its position without letting the crew change it. |
| id | string | undefined | – | The latitude degrees input's id, for a Label's htmlFor. |
| aria-label | string | undefined | – | Names the field when there is no label. |
| className | string | undefined | – | Classes added to the field's root. |
| testId | string | undefined | – | data-testid on the field. |
| ref | Ref<HTMLDivElement> | undefined | – | The field's root element. |
| motion | MotionProp | undefined | – | How the error line moves; as FormField's, an error that appears rises in by default. |
Related
- Outlined field: the box every maritime input draws in.
- MMSI input: the other identity a distress call carries.
- Data visualization: how positions and other values are shown.