Skip to content
cennge-ui
GitHubv0.3.1, changelog

Routes and arcs

Lines between places, drawn into the map under its place names: a route along points, or curves joining pairs of them.

  1. Switch the accent at the top of the page: the van's route takes the new colour, and the map does not reload.
  2. Switch the theme: the base map is redrawn, and the routes come back over it in the new theme's colours.
  3. Turn a route off below: it leaves the map with its component.
Live: the component itself, not a picture of it.

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 coordinates or arcs replace 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.

basic.tsx
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.

Delivered so far40%
progress.tsx
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.

arcs.tsx
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.

Props#

MapRoute#

PropTypeDefault
coordinatesrequired
LngLat[]The line's points, in order.
–
color
"default""primary""secondary""success""warning""danger""info"
"primary"
width
numberIn pixels.
4
dashed
boolean
–
opacity
number
1
hasOutline
booleanAn edge in the background colour, so the line reads over any street or park.
true

MapArc#

PropTypeDefault
arcsrequired
readonly { from: LngLat; to: LngLat }[]Pairs of points, each joined by a curve: flights, shipments, transfers.
–
curvature
numberHow far a curve bows out, as a share of its length; negative bows the other way.
0.2
width
numberIn pixels.
2
color
"default""primary""secondary""success""warning""danger""info"
"primary"
opacity
number
1
dashed
boolean
–
hasOutline
booleanAn edge in the background colour, so the line reads over any street or park.
true