Clustered map
A zoomable dot map for many points: nearby points merge into counted clusters that split apart as you zoom in and pull back together as you zoom out.
Your data
Pass your locations as points ({ id, lat, lon, label? }). Without it the component shows 600 demo points around large cities: tight metro groups plus a wider scatter across each region. See Your data.
Playground
Change a prop and the component re-renders. Props marked remounts set an initial value, so the component starts over.
<GeoCluster
defaultZoom={1}
maxZoom={6}
cellSize={44}
label="Clustered map"
/>Installation
pnpm dlx shadcn@latest add @lumesec/geo-clusterFirst timeRegister the @lumesec registry once, or install by URL+
Adds @lumesec to your components.json:
pnpm dlx shadcn@latest registry add @lumesec=https://elements.lumesec.ai/r/{name}.jsonOr skip the setup and install by URL:
npx shadcn@latest add https://elements.lumesec.ai/r/geo-cluster.jsonUsage
import { GeoCluster, type ClusterPoint } from "@/components/lumesec/geo-cluster";
const stores: ClusterPoint[] = [
{ id: "s1", lat: 51.5072, lon: -0.1276, label: "London Bridge" },
{ id: "s2", lat: 51.5155, lon: -0.0922, label: "Bank" },
{ id: "s3", lat: 48.8566, lon: 2.3522, label: "Paris Centre" },
];
export function Example() {
return <GeoCluster points={stores} onPointClick={(store) => console.log(store.id)} />;
}Behaviour
A zoomable map for many points. Nearby points merge into clusters with a count; zooming in splits each cluster into its children, which spring out from where the parent was, and zooming out pulls them back together while the counts roll.
The land dots are re-sampled at the same pixel pitch for every zoom level, so coastlines sharpen as you zoom. Use Ctrl or ⌘ with the wheel, pinch, double-click, or the + and − buttons.
API reference
Props
Also accepts every prop of <div> (React.ComponentProps<"div">), spread onto the root element.
pointsTypereadonly ClusterPoint[]Default600 demo pointsThe locations, as { id, lat, lon, label? }. Without it the map shows demo points around large cities; an empty array shows an empty map with a note. Entries with a non-finite latitude or longitude, and repeated ids, are skipped. A new array rebuilds the clusters.
zoomTypenumberControlled zoom, 1 to maxZoom. Use with onZoomChange. A new value springs the view there about the frame centre.
defaultZoomTypenumberDefault1Zoom when uncontrolled. The reset button and the 0 key return to it.
onZoomChangeType(zoom: number) => voidCalled when the view comes to rest at a new zoom (wheel, pinch, double-click, keys, buttons or a cluster click), with the zoom rounded to two decimals.
centerTypeLatLonControlled view centre, { lat, lon }. Use with onCenterChange. It is clamped so the map always fills the frame: at zoom 1 the whole width of the world is visible, so only the latitude can move.
defaultCenterTypeLatLonDefault{ lat: 25, lon: 10 }View centre when uncontrolled. The reset button and the 0 key return to it.
onCenterChangeType(center: LatLon) => voidCalled when a pan or a zoom settles at a new centre, rounded to four decimals.
maxZoomTypenumberDefault6Largest zoom, 1 to 6. Each whole zoom is one cluster level. The land data has a 0.5° grid, so 6 is the limit.
cellSizeTypenumberDefault44Clustering distance in px, 16 to 160: at each level, points closer than this on screen merge into one cluster.
valueTypestring | nullSelected point id (controlled). Use with onValueChange.
defaultValueTypestring | nullDefaultnullSelected point id when uncontrolled.
onValueChangeType(value: string | null) => voidCalled when a point is selected, or with null when the selection is cleared (Escape, or a click on the empty map).
onPointClickType(point: ClusterPoint) => voidCalled when a single point is clicked, or picked from a cluster's list.
onClusterClickType(points: readonly ClusterPoint[]) => boolean | voidCalled when a cluster is clicked, with all its points. Return false to stop the zoom-in (or the list). A second click within 400 ms, the rest of a double-click, is ignored.
labelTypestringDefault"Clustered map"Accessible name of the map.
Ref
ref points at the root HTMLDivElement.
Keyboard
| Keys | Action |
|---|---|
| Tab | Moves from the map to one cluster or point (a roving tab stop), then to the zoom buttons. |
| ArrowLeft / ArrowRight / ArrowUp / ArrowDown | On the map: pan by 60 px. On a cluster or point: move to the nearest node in that direction inside the frame. |
| + / = | Zoom in to the next whole zoom. |
| - / _ | Zoom out to the previous whole zoom. |
| 0 | Reset to defaultZoom and defaultCenter. |
| Enter / Space | On a cluster: zoom in until it splits, or open its list when zooming cannot split it (again: close the list). On a point: select it. |
| ArrowUp / ArrowDown / Home / End | In a cluster's list: move between the rows. |
| Escape | Close the cluster list, or clear the selection. |
| Wheel | Zooms about the pointer while the map has focus, or anywhere with Ctrl or ⌘ held. Without either the page scrolls and a short hint appears. |
Accessibility
- The map is a focusable
role="group"witharia-roledescription="map",aria-labelfromlabeland a description of its keys. - Clusters and points are native buttons in one roving tab stop. A cluster's
aria-labelreads like "34 locations around Lagos" (the nearest of 60 reference cities within 900 km, else its coordinates); a point's is itslabelor its coordinates. Points havearia-pressedfor the selection. - A cluster that holds the selected point adds ", includes the selected location" to its label and gets an accent ring.
- Nodes merging into a parent are
inertandaria-hiddenwhile they fly. When the focused node splits or merges, focus moves to its largest child or to its parent. Arrow keys skip nodes outside the frame. - When the view settles, a polite live region announces the zoom and what is in the frame, for example "Zoom 3×, 12 clusters, 4 points".
- The zoom buttons are native buttons with
aria-label("Zoom out", "Zoom in", "Reset view") and are disabled at the zoom limits. The zoom readout isaria-hidden. - A cluster that zooming cannot split has
aria-haspopup="dialog"andaria-expanded. It opens arole="dialog"list of its points, sorted by name; focus moves to the first row and returns to the cluster when the list closes. - Both canvases are
aria-hidden; every count, name and coordinate is DOM text. - Reduced motion: the zoom and pan jump without springs, clusters swap without splitting or merging, counts and the zoom readout swap without rolling, the land dots replace each other without a crossfade or the first reveal, and selections play no ripple or sparks.
Theming
Styled with Tailwind classes on your shadcn theme tokens, so light and dark follow your theme. The accent comes from --lumesec. See Theming.
This component reads --lumesec, --lumesec-glint, --card, --border, --foreground, --muted-foreground, --popover, --popover-foreground, --accent and --background.
Notes
- The map is Mercator, cut at 78° north and south, with its central meridian at 10° E so the side edges run through the Bering Strait. At zoom 1 the world fills the frame's width; the frame is 2:1 and at most 660 px wide (
max-w-[660px]), so pass a width class to change it. - Clusters are computed once per data set, cell size and frame width, for every whole zoom from 1 to
maxZoom. The current level is the zoom rounded down, so a cluster splits when the zoom crosses the next whole number. - Clicking a cluster glides it to the centre and zooms in until it splits. Points with identical coordinates, and clusters that are still closer than
cellSizeatmaxZoom, open a list instead. - The land dots are re-sampled at a 4 px pitch for each settled view, 120 ms after the zoom comes to rest, and crossfade over the previous lattice. While zooming out, the previous lattice drops every other row and column so its dots keep their spacing.
- At most 160 nodes are in the DOM: only those in or near the frame are rendered.
- One-finger touch pans only when zoomed in; at zoom 1 the page scrolls. Two fingers pinch to zoom.
- The cluster label uses the nearest reference city only as a place name. Your points and labels are shown as given.
- The animation loop runs only while something moves and pauses while the map is off screen.
Related
Was this page helpful?