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.
- Point at an area: it is outlined, and its tooltip follows the pointer. On a phone, tap it.
- Switch the measure: the shades, their colour and the legend change together.
- Switch the theme: on a dark map the shades run from dark to light, so the busiest areas stand out on either.
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 withonItemClick - Keyboard
- Tab Enter Space
- State
- Props only;
onFeatureClickandonItemClickreport 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.
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.
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.
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 passonItemClick, named bygetLabel, which is also their tooltip. - MapLegend is a group named by its
titlewhen the title is a string. Its numbers follow the provider'slocaleunless you passformat.