Skip to content
cennge-ui
GitHubv0.3.1, changelog

Data layers

Your GeoJSON on the map, shaded by a number with a legend to read it, and thousands of points grouped where they crowd.

  1. Point at an area: it is outlined, and its tooltip follows the pointer. On a phone, tap it.
  2. Switch the measure: the shades, their colour and the legend change together.
  3. Switch the theme: on a dark map the shades run from dark to light, so the busiest areas stand out on either.
Live: the component itself, not a picture of it.

Field marks

Import
import { MapGeoJSON, MapClusterLayer, MapLegend } from "cennge-ui/map";
Size
9.6 KB gzip for this import, its dependencies aside
Pattern
Canvas layers with a tooltip at the pointer; MapClusterLayer's groups are buttons, and so are its points with onItemClick
Keyboard
Tab Enter Space
State
Props only; onFeatureClick and onItemClick report what was picked
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Shades
Steps 100, 400 and 700 of the colour on a light map; 800, 500 and 300 on a dark one
In the page
Only the groups and points in view
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: map-data
Source
src/map

When to use#

Use MapGeoJSON to draw data you have as GeoJSON: areas, lines and points in one colour, or shaded by a number in their properties, a choropleth. Pair a choropleth with a MapLegend in the same colour and domain, so the shades can be read as numbers.

Use MapClusterLayer for many points that people pick one of: stores, pickup points, sensors. It groups the points that crowd, and a group zooms in until it comes apart.

Similar#

  • MapMarker is a place with a popup, for a handful of them; a MapClusterLayer makes markers of thousands.
  • MapRoute draws one line you build from points; MapGeoJSON draws whatever a GeoJSON file holds.
  • A BarChart of the same numbers by area name is easier to compare exactly; the map shows where they are.

Shade areas by a number#

valueProperty names the number in each feature's properties; domain sets the values at the two ends of the scale, the data's lowest and highest when you leave it out. Give the legend the same domain. A click reports the feature's properties to onFeatureClick.

Click an area to pick it.

choropleth.tsx
import { useMemo, useState } from "react";
import { Map, MapGeoJSON, MapLegend, type LngLat } from "cennge-ui/map";

/** Hexagons around Midtown, each with the deliveries it gets in a day. */
function hexagons() {
  const center: LngLat = [-73.975, 40.755];
  const size = 0.0045; // degrees of latitude
  const stretch = 1 / Math.cos((center[1] * Math.PI) / 180);
  const features = [];
  for (let q = -5; q <= 5; q++) {
    for (let r = -5; r <= 5; r++) {
      if (Math.abs(q + r) > 5) continue;
      const x = center[0] + size * 1.5 * q * stretch;
      const y = center[1] + size * Math.sqrt(3) * (r + q / 2);
      const ring = Array.from({ length: 7 }, (_, corner) => {
        const angle = (Math.PI / 3) * (corner % 6);
        return [x + size * Math.cos(angle) * stretch, y + size * Math.sin(angle)];
      });
      const deliveries = Math.round(Math.max(0, 90 - Math.hypot(q, r, q + r) * 12 + Math.sin(q * 1.3 - r) * 10));
      features.push({
        type: "Feature" as const,
        properties: { deliveries },
        geometry: { type: "Polygon" as const, coordinates: [ring] },
      });
    }
  }
  return { type: "FeatureCollection" as const, features };
}

export default function DeliveriesMap() {
  const cells = useMemo(hexagons, []);
  const [picked, setPicked] = useState<number | null>(null);

  return (
    <div className="flex w-full flex-col gap-3">
      <Map
        aria-label="Deliveries a day around Midtown"
        defaultBounds={[
          [-74.026, 40.712],
          [-73.924, 40.798],
        ]}
        // Room for the legend below the hexagons.
        boundsPadding={{ top: 16, right: 16, bottom: 104, left: 16 }}
        scrollZoom={false}
        className="h-96"
      >
        <MapGeoJSON
          data={cells}
          valueProperty="deliveries"
          domain={[0, 90]}
          tooltip={(cell) => `${String(cell.deliveries)} deliveries a day`}
          onFeatureClick={(cell) => setPicked(Number(cell.deliveries))}
        />
        <MapLegend title="Deliveries a day" domain={[0, 90]} />
      </Map>
      <p className="text-sm text-muted">
        {picked === null ? "Click an area to pick it." : `Picked an area with ${picked} deliveries a day.`}
      </p>
    </div>
  );
}

Areas, lines and points#

Without valueProperty everything is drawn in one colour, each feature as what it is: areas filled and edged, lines stroked, points as dots. data can also be the URL of a GeoJSON file; then give domain too, as the lowest and highest values are read only from data passed as an object.

Click a zone, the shuttle or a depot.

zones.tsx
import { useState } from "react";
import { Map, MapGeoJSON } from "cennge-ui/map";

// Areas, a line and points in one collection: each is drawn as what it is.
const network = {
  type: "FeatureCollection" as const,
  features: [
    {
      type: "Feature" as const,
      properties: { name: "Zone A: Lower Manhattan" },
      geometry: {
        type: "Polygon" as const,
        coordinates: [[[-74.016, 40.703], [-74.012, 40.727], [-73.985, 40.73], [-73.973, 40.714], [-74.0, 40.701], [-74.016, 40.703]]],
      },
    },
    {
      type: "Feature" as const,
      properties: { name: "Zone B: Downtown Brooklyn" },
      geometry: {
        type: "Polygon" as const,
        coordinates: [[[-73.998, 40.702], [-73.972, 40.704], [-73.97, 40.684], [-73.996, 40.683], [-73.998, 40.702]]],
      },
    },
    {
      type: "Feature" as const,
      properties: { name: "Shuttle between the depots" },
      geometry: { type: "LineString" as const, coordinates: [[-74.004, 40.718], [-73.992, 40.707], [-73.985, 40.694]] },
    },
    { type: "Feature" as const, properties: { name: "Depot: Tribeca" }, geometry: { type: "Point" as const, coordinates: [-74.004, 40.718] } },
    { type: "Feature" as const, properties: { name: "Depot: Downtown Brooklyn" }, geometry: { type: "Point" as const, coordinates: [-73.985, 40.694] } },
  ],
};

export default function DeliveryZones() {
  const [picked, setPicked] = useState<string | null>(null);

  return (
    <div className="flex w-full flex-col gap-3">
      <Map
        aria-label="Delivery zones and depots"
        defaultBounds={[
          [-74.016, 40.683],
          [-73.97, 40.73],
        ]}
        scrollZoom={false}
        className="h-80"
      >
        <MapGeoJSON
          data={network}
          color="secondary"
          fillOpacity={0.35}
          tooltip={(feature) => String(feature.name)}
          onFeatureClick={(feature) => setPicked(String(feature.name))}
        />
      </Map>
      <p className="text-sm text-muted">{picked ? `Picked ${picked}.` : "Click a zone, the shuttle or a depot."}</p>
    </div>
  );
}

Thousands of points#

MapClusterLayer takes your items and two functions: where each one is and what it is called. MapLibre builds the groups; the library draws them as markers sized by their count, and only the ones in view are in the page. A group's name comes from clusterLabel, so a reader can say the number they see to voice control.

Click a group to zoom in, then a point to pick it.

clusters.tsx
import { useMemo, useState } from "react";
import { Map, MapClusterLayer, MapControls, type LngLat } from "cennge-ui/map";

const offices: LngLat[] = [
  [-73.9855, 40.758],
  [-73.9888, 40.7033],
  [-73.8331, 40.7675],
  [-73.8977, 40.8622],
];

/** 2,000 pickup points around the offices, the same on every load. */
function pickupPoints() {
  let seed = 11;
  const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
  return Array.from({ length: 2000 }, (_, index) => {
    const [lng, lat] = offices[index % offices.length]!;
    const spread = index % 7 === 0 ? 0.06 : 0.03;
    return {
      name: `Pickup point ${index + 1}`,
      lngLat: [lng + (random() - 0.5) * spread * 1.8, lat + (random() - 0.5) * spread] as LngLat,
    };
  });
}

export default function PickupPoints() {
  const points = useMemo(pickupPoints, []);
  const [picked, setPicked] = useState<string | null>(null);

  return (
    <div className="flex w-full flex-col gap-3">
      <Map
        aria-label="Pickup points"
        defaultBounds={[
          [-74.05, 40.66],
          [-73.8, 40.89],
        ]}
        // Groups clear of the buttons on the right.
        boundsPadding={{ top: 48, right: 72, bottom: 48, left: 48 }}
        scrollZoom={false}
        className="h-96"
      >
        <MapClusterLayer
          items={points}
          getLngLat={(point) => point.lngLat}
          getLabel={(point) => point.name}
          onItemClick={(point) => setPicked(point.name)}
          clusterLabel={(_count, shown) => `${shown} pickup points`}
        />
        <MapControls showCompass={false} />
      </Map>
      <p className="text-sm text-muted">{picked ? `Picked ${picked}.` : "Click a group to zoom in, then a point to pick it."}</p>
    </div>
  );
}

Accessibility#

  • What MapGeoJSON draws is a picture on the canvas: screen readers do not read it, and its tooltip shows for a pointer or a tap, not for the keyboard. Give the same numbers another way as well, say a Table of the areas beside the map.
  • MapClusterLayer's groups are buttons in the tab order, named by clusterLabel. Its points are buttons too when you pass onItemClick, named by getLabel, which is also their tooltip.
  • MapLegend is a group named by its title when the title is a string. Its numbers follow the provider's locale unless you pass format.

Props#

MapGeoJSON#

PropTypeDefault
datarequired
GeoJSONSourceSpecification["data"]A FeatureCollection, a single feature, or the URL of one. Areas, lines and points are all drawn.
–
color
"default""primary""secondary""success""warning""danger""info"
"primary"
valueProperty
stringShades each feature by this number in its properties, along the colour's scale: a choropleth.
–
domain
[min: number, max: number]The values at the light and the dark end.
the data's lowest and highest (give it for a URL)
fillOpacity
numberOf areas.
0.75
hasOutline
booleanThin edges between areas.
true
tooltip
(properties: Properties) => React.ReactNodeShown at the pointer over a feature, or where it was tapped.
–
onFeatureClick
(properties: Properties, lngLat: LngLat) => void
–

MapLegend#

PropTypeDefault
titlerequired
React.ReactNodeWhat the shades stand for, e.g. "Downloads per month".
–
color
"default""primary""secondary""success""warning""danger""info"The colour of the MapGeoJSON it explains.
"primary"
domainrequired
[min: number, max: number]Its domain: the values at the two ends.
–
format
(value: number) => string
numbers in the provider's locale
position
"top-right""top-left""bottom-right""bottom-left"
"bottom-left"
className
string
–

MapClusterLayer#

PropTypeDefault
itemsrequired
readonly T[]
–
getLngLatrequired
(item: T) => LngLat
–
getLabelrequired
(item: T) => stringEach point's name, read by screen readers and shown as its tooltip.
–
onItemClick
(item: T) => void
–
color
"default""primary""secondary""success""warning""danger""info"
"primary"
clusterLabel
(count: number, shown: string) => stringA group's name, e.g. (count, shown) => ${shown} stores. shown is the number on the group, in the locale's digits: put it in the name, so what is seen can be said (voice control, WCAG 2.5.3).
"{shown} points"
radius
numberHow close points join a group, in pixels.
60
maxZoom
numberFrom this zoom on, every point stands alone.
14