Routes and arcs
Lines between places, drawn into the map under its place names: a route along points, or curves joining pairs of them.
- Switch the accent at the top of the page: the van's route takes the new colour, and the map does not reload.
- Switch the theme: the base map is redrawn, and the routes come back over it in the new theme's colours.
- Turn a route off below: it leaves the map with its component.
Field marks
- Import
import { MapRoute, MapArc } from "cennge-ui/map";- Size
- 6.4 KB gzip for this import, its dependencies aside
- Pattern
- MapLibre line layers in the canvas, under the place names; not in the page's DOM
- State
- Props only: new
coordinatesorarcsreplace the line's data in place - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Colour
- A colour token, read again when the theme or the accent changes
- Arcs
- Quadratic curves, the short way across the 180th meridian
- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: map-data, map
- Source
- src/map
When to use#
Use a MapRoute for a way along points in order: a delivery, a track, a walk. Use a MapArc for pairs of places where the way between them does not matter, only that they are joined: shipments, transfers, flights.
Both draw into the canvas below the map's labels, so street and borough names stay readable over them. Their colour is one of the library's roles, primary by default, which follows the accent. Neither is read out: name the places in text or with markers, as the examples do.
Similar#
- MapGeoJSON draws lines from GeoJSON too, with a tooltip on hover and a colour by value. Use it for many lines that carry data, a MapRoute for one way you draw.
- A Timeline lists the stops of a route in order, with times: a good companion for readers who cannot see the map.
A route through stops#
coordinates are the line's points in order, as [longitude, latitude]. An edge in the background's colour runs under the line, so it reads over any street or park; hasOutline={false} drops it.
import { Map, MapMarker, MapRoute, type LngLat } from "cennge-ui/map";
const stops: { name: string; lngLat: LngLat }[] = [
{ name: "Manhattan office", lngLat: [-73.9855, 40.758] },
{ name: "Bronx office", lngLat: [-73.8977, 40.8622] },
{ name: "Queens office", lngLat: [-73.8331, 40.7675] },
{ name: "Brooklyn office", lngLat: [-73.9888, 40.7033] },
];
// The van's way between the stops, along the main roads, roughly.
const route: LngLat[] = [
[-73.9855, 40.758],
[-73.944, 40.804],
[-73.93, 40.835],
[-73.8977, 40.8622],
[-73.825, 40.8],
[-73.8331, 40.7675],
[-73.87, 40.73],
[-73.93, 40.71],
[-73.9888, 40.7033],
];
export default function VanRoute() {
return (
<Map
aria-label="Van route: Manhattan, the Bronx, Queens, Brooklyn"
defaultBounds={[
[-73.99, 40.7],
[-73.825, 40.862],
]}
scrollZoom={false}
className="h-80"
>
<MapRoute coordinates={route} />
{stops.map((stop) => (
<MapMarker key={stop.name} lngLat={stop.lngLat} label={stop.name} tooltip={stop.name} color="default" size="sm" />
))}
</Map>
);
}Travelled and still to go#
New coordinates replace the line's data without adding it again, so a line can follow a slider or live positions. Here two routes share the way: the part still to go is thin, dashed and grey, and the part travelled, drawn after it, lies on top.
import { useState } from "react";
import { Slider } from "cennge-ui";
import { Map, MapMarker, MapRoute, type LngLat } from "cennge-ui/map";
// Midtown down Broadway, over the Brooklyn Bridge to Dumbo.
const route: LngLat[] = [
[-73.9855, 40.758],
[-73.9903, 40.7359],
[-73.9967, 40.7251],
[-74.0021, 40.7192],
[-74.0067, 40.7143],
[-74.0028, 40.7116],
[-73.9934, 40.7041],
[-73.9888, 40.7033],
];
/** The line cut at a share of its length: the part travelled and the part still to go. */
function cut(line: LngLat[], share: number): [LngLat[], LngLat[]] {
// A degree of longitude is about 0.76 of a degree of latitude in New York.
const lengths = line.slice(1).map(([lng, lat], index) => Math.hypot((lng - line[index]![0]) * 0.76, lat - line[index]![1]));
let left = share * lengths.reduce((sum, length) => sum + length, 0);
for (const [index, length] of lengths.entries()) {
if (left <= length) {
const [from, to] = [line[index]!, line[index + 1]!];
const t = length ? left / length : 0;
const here: LngLat = [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t];
return [[...line.slice(0, index + 1), here], [here, ...line.slice(index + 1)]];
}
left -= length;
}
return [line, line.slice(-2)];
}
export default function DeliveryProgress() {
const [percent, setPercent] = useState(40);
const [travelled, toGo] = cut(route, percent / 100);
return (
<div className="flex w-full flex-col gap-4">
<Map
aria-label="Delivery from Midtown to Dumbo"
defaultBounds={[
[-74.0067, 40.7033],
[-73.9855, 40.758],
]}
scrollZoom={false}
className="h-96"
>
<MapRoute coordinates={toGo} color="default" width={3} dashed />
<MapRoute coordinates={travelled} />
<MapMarker lngLat={travelled.at(-1)!} label="The van" size="lg" />
</Map>
<Slider label="Delivered so far" value={percent} onValueChange={setPercent} formatValue={(value) => `${value}%`} className="w-full max-w-80" />
</div>
);
}Arcs between places#
arcs takes pairs of points and joins each with a curve. curvature sets how far it bows, as a share of its length, 0.2 by default; a negative one bows the other way. A curve bows to the left of its way, so a trip there and one back are two curves, not one. Arcs are 2 px wide and, unlike routes, have no edge under them unless you pass hasOutline.
import { Map, MapArc, MapMarker, type LngLat } from "cennge-ui/map";
const manhattan: LngLat = [-73.9855, 40.758];
const offices: { name: string; lngLat: LngLat }[] = [
{ name: "Bronx office", lngLat: [-73.8977, 40.8622] },
{ name: "Queens office", lngLat: [-73.8331, 40.7675] },
{ name: "Brooklyn office", lngLat: [-73.9888, 40.7033] },
];
export default function Shipments() {
return (
<Map
aria-label="Shipments from the Manhattan office"
defaultBounds={[
[-74.0, 40.7],
[-73.83, 40.862],
]}
scrollZoom={false}
className="h-80"
>
{/* Each curve bows to the left of its way: out and back are two curves, not one. */}
<MapArc arcs={offices.map((office) => ({ from: manhattan, to: office.lngLat }))} width={3} />
<MapArc arcs={offices.map((office) => ({ from: office.lngLat, to: manhattan }))} color="secondary" dashed />
<MapMarker lngLat={manhattan} label="Manhattan office" tooltip="Manhattan office" />
{offices.map((office) => (
<MapMarker key={office.name} lngLat={office.lngLat} label={office.name} tooltip={office.name} color="default" size="sm" />
))}
</Map>
);
}Accessibility#
A line on the canvas is a picture: screen readers do not see it. Put what it shows in text as well: the stops in order, the places joined. Markers at the stops are read out by name, and the map's aria-label can say where the route goes.