Skip to content
NewYour data: charts, lists and meters that take your records
<GeoCluster />ReactMaps & trafficFree

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.

View as Markdown
<GeoCluster />

Tryclick a large cluster to split it, then zoom back out with the − button.

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.

Loading <GeoCluster />
Props
defaultZoomremounts
maxZoomlive
cellSizelive
labellive
React
<GeoCluster
  defaultZoom={1}
  maxZoom={6}
  cellSize={44}
  label="Clustered map"
/>

Installation

pnpm dlx shadcn@latest add @lumesec/geo-cluster
First 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}.json

Or skip the setup and install by URL:

npx shadcn@latest add https://elements.lumesec.ai/r/geo-cluster.json

Usage

geo-cluster-example.tsx
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 points

The 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.

zoomTypenumber

Controlled zoom, 1 to maxZoom. Use with onZoomChange. A new value springs the view there about the frame centre.

defaultZoomTypenumberDefault1

Zoom when uncontrolled. The reset button and the 0 key return to it.

onZoomChangeType(zoom: number) => void

Called 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.

centerTypeLatLon

Controlled 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) => void

Called when a pan or a zoom settles at a new centre, rounded to four decimals.

maxZoomTypenumberDefault6

Largest zoom, 1 to 6. Each whole zoom is one cluster level. The land data has a 0.5° grid, so 6 is the limit.

cellSizeTypenumberDefault44

Clustering distance in px, 16 to 160: at each level, points closer than this on screen merge into one cluster.

valueTypestring | null

Selected point id (controlled). Use with onValueChange.

defaultValueTypestring | nullDefaultnull

Selected point id when uncontrolled.

onValueChangeType(value: string | null) => void

Called when a point is selected, or with null when the selection is cleared (Escape, or a click on the empty map).

onPointClickType(point: ClusterPoint) => void

Called when a single point is clicked, or picked from a cluster's list.

onClusterClickType(points: readonly ClusterPoint[]) => boolean | void

Called 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

KeysAction
TabMoves from the map to one cluster or point (a roving tab stop), then to the zoom buttons.
ArrowLeft / ArrowRight / ArrowUp / ArrowDownOn 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.
0Reset to defaultZoom and defaultCenter.
Enter / SpaceOn 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 / EndIn a cluster's list: move between the rows.
EscapeClose the cluster list, or clear the selection.
WheelZooms 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" with aria-roledescription="map", aria-label from label and a description of its keys.
  • Clusters and points are native buttons in one roving tab stop. A cluster's aria-label reads like "34 locations around Lagos" (the nearest of 60 reference cities within 900 km, else its coordinates); a point's is its label or its coordinates. Points have aria-pressed for 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 inert and aria-hidden while 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 is aria-hidden.
  • A cluster that zooming cannot split has aria-haspopup="dialog" and aria-expanded. It opens a role="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 cellSize at maxZoom, 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.

Was this page helpful?