# 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.
- React: `import { GeoCluster } from "@/components/lumesec/geo-cluster"`
- Collection: Location (https://elements.lumesec.ai/components/location)
- Data: takes your data (`data`); shows demo data until you pass it
- Registry item: https://elements.lumesec.ai/r/geo-cluster.json
- Page: https://elements.lumesec.ai/components/location/cluster



Live preview: https://elements.lumesec.ai/view/geo-cluster

Demo source:

```tsx
"use client";

import * as React from "react";

import { GeoCluster, type ClusterPoint } from "@/components/lumesec/geo-cluster";
import { formatLatLon } from "@/lib/lumesec/geo-data";

export default function GeoClusterDemo() {
  const [point, setPoint] = React.useState<ClusterPoint | null>(null);

  return (
    <div className="grid w-full max-w-[660px] gap-2.5">
      <GeoCluster
        onPointClick={setPoint}
        onValueChange={(id) => {
          if (id === null) setPoint(null);
        }}
      />
      <p className="m-0 flex min-h-[18px] items-baseline gap-2 px-1 text-[12.5px] leading-[1.4] text-muted-foreground" aria-live="polite">
        {point ? (
          <>
            <span className="font-medium text-foreground">{point.label ?? "Selected location"}</span>
            <span className="font-mono text-[11.5px] tabular-nums">{formatLatLon(point, "decimal", 4)}</span>
          </>
        ) : (
          <span>Select a point to see its name and coordinates.</span>
        )}
      </p>
    </div>
  );
}
```

> **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](/docs/data).

## Playground

Change a prop and the component re-renders. Props marked remounts set an initial value, so the component starts over.

## Installation

```bash
npx shadcn@latest add @lumesec/geo-cluster
```

First time with the @lumesec registry? Register it once, or install by URL:

```bash
npx shadcn@latest registry add @lumesec=https://elements.lumesec.ai/r/{name}.json
npx shadcn@latest add https://elements.lumesec.ai/r/geo-cluster.json
```

## Usage

React:

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

| Prop             | Type                                                   | Default                | Description                                                                                                                                                                                                                                                               |
| ---------------- | ------------------------------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `points`         | `readonly ClusterPoint[]`                              | `600 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. |
| `zoom`           | `number`                                               | —                      | Controlled zoom, 1 to `maxZoom`. Use with `onZoomChange`. A new value springs the view there about the frame centre.                                                                                                                                                      |
| `defaultZoom`    | `number`                                               | `1`                    | Zoom when uncontrolled. The reset button and the `0` key return to it.                                                                                                                                                                                                    |
| `onZoomChange`   | `(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.                                                                                                                   |
| `center`         | `LatLon`                                               | —                      | 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.                                                                     |
| `defaultCenter`  | `LatLon`                                               | `{ lat: 25, lon: 10 }` | View centre when uncontrolled. The reset button and the `0` key return to it.                                                                                                                                                                                             |
| `onCenterChange` | `(center: LatLon) => void`                             | —                      | Called when a pan or a zoom settles at a new centre, rounded to four decimals.                                                                                                                                                                                            |
| `maxZoom`        | `number`                                               | `6`                    | 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.                                                                                                                                                             |
| `cellSize`       | `number`                                               | `44`                   | Clustering distance in px, 16 to 160: at each level, points closer than this on screen merge into one cluster.                                                                                                                                                            |
| `value`          | `string \| null`                                       | —                      | Selected point id (controlled). Use with `onValueChange`.                                                                                                                                                                                                                 |
| `defaultValue`   | `string \| null`                                       | `null`                 | Selected point id when uncontrolled.                                                                                                                                                                                                                                      |
| `onValueChange`  | `(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).                                                                                                                                                      |
| `onPointClick`   | `(point: ClusterPoint) => void`                        | —                      | Called when a single point is clicked, or picked from a cluster's list.                                                                                                                                                                                                   |
| `onClusterClick` | `(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.                                                                                            |
| `label`          | `string`                                               | `"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"` 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](/docs/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.


