@meetreeve/maps-react
v0.6.1
Published
Reeve Maps React components and API clients
Keywords
Readme
@meetreeve/maps-react
The Reeve Maps frontend SDK. Provides typed API clients (geocode, getMapsConfig, fetchStaticImageBlob) that talk to your app's backend proxy of the reeve-services maps API, a choroplethSpec utility for computing Mapbox paint expressions and legend items, a <StaticMapImage> component for server-rendered or non-interactive map tiles, an interactive <ReeveMap> wrapper over react-map-gl/mapbox-gl with <ChoroplethLayer> and <HeatmapLayer> children (plus an onViewportChange viewport reporter and a useViewportQuery debounced viewport→fetch hook), an unstyled <ChoroplethLegend> for rendering choroplethSpec(...).legend, and a computeBounds helper for deriving fitBounds from a GeoJSON FeatureCollection.
Install
pnpm add @meetreeve/maps-reactPeer dependencies
pnpm add react react-dom mapbox-gl@^3 react-map-gl@^8You must also import the Mapbox CSS once in your app (e.g. in your root layout or global CSS entry):
import "mapbox-gl/dist/mapbox-gl.css";Next.js: SSR note
<ReeveMap> renders react-map-gl / mapbox-gl, which touch window at import time and cannot run on the server. Wrap it with next/dynamic:
// components/MapShell.tsx (client component file)
import dynamic from "next/dynamic";
const ReeveMap = dynamic(
() => import("@meetreeve/maps-react").then((m) => ({ default: m.ReeveMap })),
{ ssr: false }
);
// — or mark your whole component "use client" and lazy-import at the top:
// "use client";
// const ReeveMap = dynamic(..., { ssr: false });<StaticMapImage> does not depend on mapbox-gl and can be used freely in server or client components.
API clients
All three clients accept a base string that points at your app's own backend proxy of the reeve-services maps API (e.g. /api/maps/v1). The proxy forwards requests to reeve-services over the host key — credentials never reach the browser.
getMapsConfig(base, init?)
Fetches the Mapbox public token, default style, center, and zoom from the backend.
import { getMapsConfig } from "@meetreeve/maps-react";
const config = await getMapsConfig("/api/maps/v1");
// { style, public_token, default_center, default_zoom, attribution }geocode(base, body, init?)
Forward-geocodes an address or place name.
import { geocode } from "@meetreeve/maps-react";
const { results } = await geocode("/api/maps/v1", {
query: "123 Main St, Austin, TX",
country: "US",
limit: 5,
});
// results: Array<{ lat, lng, label, relevance? }>fetchStaticImageBlob(base, params, init?)
Returns a Blob (PNG) for a static map image. Renders server-side via the reeve-services static-image endpoint.
import { fetchStaticImageBlob } from "@meetreeve/maps-react";
const blob = await fetchStaticImageBlob("/api/maps/v1", {
size: [800, 400],
center: [-97.7431, 30.2672],
zoom: 10,
pins: [{ lng: -97.7431, lat: 30.2672, color: "#E63946", label: "A" }],
retina: true,
});
const url = URL.createObjectURL(blob);<StaticMapImage>
Fetches and renders a static map image as an <img>. Manages the object URL lifecycle internally.
import { StaticMapImage } from "@meetreeve/maps-react";
<StaticMapImage
base="/api/maps/v1"
size={[640, 320]}
center={[-97.7431, 30.2672]}
zoom={10}
alt="Austin overview"
className="rounded-lg"
/>Props extend StaticImageParams (all fetchStaticImageBlob params) plus:
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| base | string | — | Backend proxy base URL |
| alt | string | "Map" | <img> alt text |
| className | string | — | CSS class on the container/img |
| fetchInit | RequestInit | — | Passed to fetch (headers, signal, etc.) |
<ReeveMap> with <ChoroplethLayer>
Interactive Mapbox map. The token can be passed directly or resolved automatically from configUrl.
"use client";
import dynamic from "next/dynamic";
import { computeBounds, choroplethSpec } from "@meetreeve/maps-react";
import type { FeatureCollection } from "geojson";
const ReeveMap = dynamic(
() => import("@meetreeve/maps-react").then((m) => ({ default: m.ReeveMap })),
{ ssr: false }
);
const ChoroplethLayer = dynamic(
() => import("@meetreeve/maps-react").then((m) => ({ default: m.ChoroplethLayer })),
{ ssr: false }
);
// --- in your component ---
const geojson: FeatureCollection = /* your data, each feature has properties.value */;
const values = geojson.features.map((f) => f.properties?.value as number);
// Compute legend (same math as reeve-services BE — FE and BE classifications agree)
const { legend } = choroplethSpec(values, { scheme: "YlOrRd", classes: 5 });
// Fit map to data bounds
const bounds = computeBounds(geojson); // BBox | null
return (
<div style={{ height: 500 }}>
<ReeveMap
configUrl="/api/maps/v1"
fitBounds={bounds}
interactiveLayerIds={["districts-fill"]}
onFeatureClick={(feature) => console.log(feature)}
>
<ChoroplethLayer
id="districts"
data={geojson}
property="value"
values={values}
scheme="YlOrRd"
classes={5}
/>
</ReeveMap>
{/* Legend */}
<ul>
{legend.map((item) => (
<li key={item.label} style={{ color: item.color }}>{item.label}</li>
))}
</ul>
</div>
);ReeveMapProps
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| token | string | — | Mapbox public token (pk.*). If omitted, fetched via configUrl. |
| configUrl | string | — | Backend proxy base; calls GET {configUrl}/config for token + style. |
| style | string | "mapbox://styles/mapbox/dark-v11" | Mapbox style URL |
| style_ | CSSProperties | — | Inline style on the container div (named style_ to avoid clash with map style) |
| initialViewState | { longitude, latitude, zoom } | — | Starting viewport |
| fitBounds | BBox \| null | — | [[minLng,minLat],[maxLng,maxLat]] fit on mount |
| fitPadding | number | 40 | Padding (px) around fitBounds |
| interactiveLayerIds | string[] | — | Layer ids to enable hover/click events on |
| onFeatureClick | (feature: unknown) => void | — | Called with the topmost feature on click |
| onFeatureHover | (feature: unknown \| null) => void | — | Called with hovered feature (or null on leave) |
| onViewportChange | (v: ViewportBounds) => void | — | Called on load and on every moveend/resize with {west, south, east, north, zoom} |
| className | string | — | CSS class on the container div |
| children | ReactNode | — | <Source>/<Layer> or <ChoroplethLayer>/<HeatmapLayer> |
| centerOn | { lat: number; lon: number } \| null | — | Recenter the map when this changes to a new finite pair (zoom untouched) |
| centerOnOptions | { animate?: boolean; duration?: number } | — | Passed through to the flyTo call centerOn triggers |
| reuseMaps | boolean | — | Passthrough to react-map-gl's <Map reuseMaps> (DEV-3199 hardening) |
| id | string | "reeve-map" | Identifies this instance for the dev-only duplicate-mount warning |
| selectedFeatureId | string \| number \| null | — | Currently-selected feature id, exposed to children via useReeveMapSelection() |
| selectionIdProperty | string | "id" | GeoJSON feature property compared against selectedFeatureId |
| onFeatureSelect | (id: string \| number, feature: unknown) => void | — | Called on click when properties[selectionIdProperty] is a string or number (requires interactiveLayerIds) |
Selection + recenter
<ReeveMap> owns selected-feature state and controlled recentering, so a
consumer doesn't need to reimplement the DEV-3463 recenter-on-navigation fix
or the "subject" GeoJSON-property hack for a selected-parcel ring.
Recenter on navigation — pass centerOn with the subject's current
coordinates; ReeveMap flies the camera whenever the lat/lon values change
(current zoom is preserved, and a new {lat, lon} object with the same values
does not re-fly):
<ReeveMap token={token} centerOn={{ lat: subject.lat, lon: subject.lon }} centerOnOptions={{ duration: 800 }}>
{/* ... */}
</ReeveMap>Selection — pass selectedFeatureId (+ optionally selectionIdProperty,
default "id") and read it back from any child via useReeveMapSelection(),
which also hands you a ready-to-use Mapbox isSelectedExpression:
import { Layer } from "react-map-gl/mapbox";
import { ReeveMap, useReeveMapSelection } from "@meetreeve/maps-react";
function SelectedRingLayer() {
const { isSelectedExpression } = useReeveMapSelection();
return (
<Layer
id="parcels-outline"
type="circle"
source="parcels"
paint={{
"circle-stroke-color": ["case", isSelectedExpression, "#34d399", "#888888"],
"circle-stroke-width": ["case", isSelectedExpression, 2, 0.5],
}}
/>
);
}
<ReeveMap
token={token}
interactiveLayerIds={["parcels-fill"]}
selectedFeatureId={selectedBbl}
selectionIdProperty="bbl"
onFeatureSelect={(id) => setSelectedBbl(id)}
>
<SelectedRingLayer />
</ReeveMap>;isSelectedExpression/selectedCaseExpression are also exported as pure
functions (from both the main entry and @meetreeve/maps-react/math) for
building the same expression outside of a component:
import { selectedCaseExpression } from "@meetreeve/maps-react/math";
const strokeColor = selectedCaseExpression("bbl", selectedBbl, "#34d399", "#888888");Single-GL-context hardening — reuseMaps passes through to react-map-gl's
pooled-map-instance support, and in development ReeveMap warns (once, via
console.warn) if a second instance sharing the same id mounts at the same
time — the DEV-3199 shape (two simultaneous WebGL contexts). Give each
concurrently-mountable map slot its own id, and gate hidden/inactive slots
so only one is ever actually mounted.
ChoroplethLayerProps
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| id | string | — | ID prefix; fill layer gets id {id}-fill |
| data | FeatureCollection | — | GeoJSON source data |
| property | string | "value" | Feature property to classify |
| values | number[] | — | All numeric values (used to compute breaks) |
| method | "quantile" \| "equal_interval" | "quantile" | Break classification method |
| classes | number | 5 | Number of classes |
| scheme | string | "YlOrRd" | Color scheme: "YlOrRd", "Blues", or "Gold" |
| breaks | number[] | — | Explicit interior break values (overrides method/classes) |
| opacity | number | 0.7 | Fill opacity |
HeatmapLayerProps
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| id | string | — | ID prefix; heatmap layer gets id {id}-heatmap |
| data | FeatureCollection | — | GeoJSON source data |
| weightProperty | string | "weight" | Feature property used as heatmap weight |
| intensity | number | 1 | Heatmap intensity multiplier |
| radius | number | 15 | Heatmap radius (px) |
choroplethSpec and classBreaks
Use these to compute legend items or Mapbox paint expressions outside of <ChoroplethLayer>.
import { choroplethSpec, classBreaks, SCHEMES } from "@meetreeve/maps-react";
// Compute breaks only
const breaks = classBreaks(values, { method: "quantile", classes: 5 });
// Compute paint expression + legend
const { paintExpression, legend } = choroplethSpec(values, {
method: "quantile",
classes: 5,
scheme: "Blues",
property: "price", // feature property key
});The break math (quantile and equal-interval) mirrors the reeve-services backend (choropleth.py) exactly — FE and BE classifications agree, so legend items rendered here match any server-side choropleth outputs.
Available schemes: "YlOrRd" (yellow-orange-red), "Blues", "Gold" (price ramp).
The return value also carries the breaks: number[] and colors: string[] actually used (explicit opts.breaks/opts.colors, or the computed/sampled values), so a consumer that paints per-feature in JS instead of handing paintExpression to Mapbox can reuse them directly with colorForValue.
Fixed-break layers (no live data)
values may be empty when breaks is passed explicitly — a fixed threshold layout (e.g. a distress/air layer with no per-viewport data) doesn't need dummy values just to build its legend:
const { legend } = choroplethSpec([], {
breaks: [25, 50, 75],
colors: ["#10b981", "#f59e0b", "#f97316", "#ef4444"],
});An explicitly empty breaks: [] combined with empty values produces a single-class legend labeled "—".
Custom labels and formatting
labels (length must equal the class count, same as colors) replaces the generated legend labels verbatim; formatValue replaces the default fmtVal number formatting used to build generated labels:
const { legend } = choroplethSpec(values, {
breaks: [25],
colors: ["#10b981", "#f43f5e"],
labels: ["Clean (<25)", "Distressed (>=25)"],
});
// or, keep generated labels but customize the number formatting:
const { legend: priceLegend } = choroplethSpec(prices, {
classes: 5,
scheme: "Gold",
formatValue: (v) => `$${v.toFixed(0)}`,
});scheme also accepts an inline array of hex colors instead of a named SCHEMES key — sampled to the class count the same way, so a fixed sequential palette (e.g. a brand ramp) doesn't need sampleColors hand-rolled again: choroplethSpec(values, { classes: 4, scheme: ["#f5e7c6", "#c9a962", "#8a5a00"] }).
colorForValue
The JS mirror of the Mapbox step expression choroplethSpec builds — for a consumer that paints per-feature in JS rather than handing paintExpression to Mapbox:
import { colorForValue } from "@meetreeve/maps-react";
const { breaks, colors } = choroplethSpec(values, { classes: 5, scheme: "YlOrRd" });
const color = colorForValue(featureValue, breaks, colors);<ChoroplethLegend>
Unstyled color-swatch legend list — pass it choroplethSpec(...).legend (or any plain {label,color}[], e.g. map-legend.tsx's current stops). Renders null when items is empty. It carries no colors, fonts, borders, positioning, or layout of its own — each <li> is a bare list item, so itemClassName fully controls its display (e.g. flex for the default swatch-then-label row, or grid for a different arrangement). Style it entirely via className/titleClassName/listClassName/itemClassName/swatchClassName/labelClassName (or wrap it), and use children for trailing content like a data-source note.
import { choroplethSpec, ChoroplethLegend } from "@meetreeve/maps-react";
const { legend } = choroplethSpec(values, { scheme: "YlOrRd", classes: 5 });
<ChoroplethLegend
items={legend}
title="Distress score"
className="absolute bottom-3 left-3 z-[500] rounded-lg bg-card/85 p-3 backdrop-blur"
listClassName="mt-2 space-y-1"
itemClassName="flex items-center gap-2 text-sm"
swatchClassName="h-3 w-3 rounded-full"
>
<p className="mt-2 text-xs text-muted-foreground">
Illustrative sample · not live (no warehouse key)
</p>
</ChoroplethLegend>;ChoroplethLegendProps
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| items | LegendItem[] \| { label, color }[] | — | Legend rows; min/max are ignored if present |
| title | string | — | Optional heading rendered above the list |
| children | ReactNode | — | Rendered after the list (e.g. a source-note footer) |
| className | string | — | Root <div role="region"> |
| titleClassName | string | — | Title <p> |
| listClassName | string | — | <ul> |
| itemClassName | string | — | Each <li> |
| swatchClassName | string | — | Each swatch <span> |
| labelClassName | string | — | Each label <span> |
| style | CSSProperties | — | Inline style on the root |
| aria-label | string | title, then "Legend" if both are absent/blank | Accessible name for the region |
Layer registry + chips
createLayerRegistry and <LayerChips> are the generic substrate for a
layer-toggle UI (e.g. parcel / distress / air rights / price-per-sf / zoning)
— the registry itself carries no product-specific data; you supply your own
LayerDef<YourFeatureType>[].
import { createLayerRegistry } from "@meetreeve/maps-react";
import type { LayerDef } from "@meetreeve/maps-react";
interface MyParcel {
distress: number | null;
zoning: string | null;
}
const LAYERS: LayerDef<MyParcel, "distress" | "zoning">[] = [
{ id: "distress", label: "Distress", value: (p) => p.distress, has: (p) => p.distress !== null },
{ id: "zoning", label: "Zoning", title: "Zoning District", value: () => null, has: (p) => p.zoning !== null },
];
const registry = createLayerRegistry(LAYERS);
// registry.layers — the input, order preserved
// registry.ids — ["distress", "zoning"]
// registry.byId.zoning.title — "Zoning District"createLayerRegistry throws if two layers share an id.
import { useState } from "react";
import { LayerChips } from "@meetreeve/maps-react";
function LayerToggle() {
const [active, setActive] = useState<"distress" | "zoning">("distress");
return (
<LayerChips
layers={registry.layers}
active={active}
aria-label="Map layers"
chipClassName={({ id, active }) => (active ? `chip chip--${id} chip--on` : `chip chip--${id}`)}
onBeforeChange={() => {
// synchronous — runs BEFORE onChange, so you can clear stale
// parcels/source/loading state before the new layer's data lands
// (avoids a one-frame flicker of the old layer under the new one).
}}
onChange={(next) => setActive(next)}
/>
);
}<LayerChips> is unstyled by design (package convention — no Tailwind/clsx,
no inline colours): style chips via className/chipClassName (string or a
({ id, active }) => string function). Each chip is a real <button
type="button" role="button" aria-pressed> whose accessible name is the
layer's label, so getByRole("button", { name: "..." }) and
fireEvent.click work directly in tests. Clicking the already-active chip
is a no-op — neither onBeforeChange nor onChange fires.
computeBounds
Derives a BBox from any FeatureCollection (supports Point, LineString, Polygon, Multi*, GeometryCollection). Returns null for an empty collection.
import { computeBounds } from "@meetreeve/maps-react";
import type { BBox } from "@meetreeve/maps-react";
const bounds: BBox | null = computeBounds(geojson);
// bounds: [[minLng, minLat], [maxLng, maxLat]]Pass the result directly to <ReeveMap fitBounds={bounds}>.
Viewport queries
onViewportChange (on <ReeveMap>) plus useViewportQuery generalize the
Cadasense map-panel pattern: a debounced viewport → bbox → refetch, with a
monotonic request guard so a slow response never overwrites a newer one, and
a zoom-aware result cap. The facet schema and fetcher stay app-specific — the
hook only owns debounce, sequencing, and loading/error/capped state.
import { ReeveMap, useViewportQuery } from "@meetreeve/maps-react";
import type { ViewportBounds } from "@meetreeve/maps-react";
function ParcelsMap() {
const { onViewportChange, items, capped, loading, error } = useViewportQuery({
fetcher: async ({ viewport, limit, signal }) => {
const res = await fetch(`/api/map-layer?bbox=${JSON.stringify(viewport)}&limit=${limit}`, {
signal,
});
return res.json(); // { items, capped, meta? }
},
// debounceMs and limitForZoom both default sensibly — override if needed
});
return (
<>
<ReeveMap token="pk.test" onViewportChange={onViewportChange} />
{capped && <p>Zoom in to see all results.</p>}
{loading && <p>Loading…</p>}
{error != null && <p>Failed to load parcels.</p>}
</>
);
}Pass deps: [layerId] (or any array) to reset items/capped and refetch
the current viewport whenever a value changes — e.g. switching the active
map layer. Pass enabled: false to suspend fetching entirely (no calls to
fetcher) while still tracking onViewportChange.
limitForZoom, DEFAULT_MAX_FEATURES, and DEFAULT_VIEWPORT_DEBOUNCE_MS are
also exported from the side-effect-free @meetreeve/maps-react/math subpath,
so a server route can import the same cap without dragging in React or
mapbox-gl:
import { limitForZoom, DEFAULT_MAX_FEATURES } from "@meetreeve/maps-react/math";