@classic-homes/maps-react
v0.2.12
Published
React MapLibre components for Classic Theme
Downloads
1,817
Readme
@classic-homes/maps-react
React components for MapLibre GL maps in the Classic Theme design system.
Installation
npm install @classic-homes/maps-react maplibre-glFeatures
- Declarative Components: React-friendly API for MapLibre GL
- 7 Layer Types: Fill, Line, Circle, Symbol, FillExtrusion, Heatmap, Raster
- Controls: Navigation, Scale, Geolocation, Legend, Coordinate Display
- Clustering: Built-in cluster support with expansion utilities
- Deck.gl Integration: Optional overlay for advanced visualizations
- Accessibility: ARIA labels, keyboard navigation, reduced motion support
- Theme Integration: Light/dark themes from design tokens
Quick Start
import { Map, MapContainer, Source, FillLayer } from '@classic-homes/maps-react';
import 'maplibre-gl/dist/maplibre-gl.css';
function MyMap() {
return (
<MapContainer className="h-[500px]">
<Map
initialCenter={[-122.4, 37.8]}
initialZoom={12}
onClick={(e) => console.log('Clicked:', e.lngLat)}
>
<Source id="lots" data={geojsonData}>
<FillLayer id="lots-fill" fillColor="#22c55e" fillOpacity={0.7} />
</Source>
</Map>
</MapContainer>
);
}Components
Map Container
import { MapContainer, MapSkeleton, MapError, MapEmpty } from '@classic-homes/maps-react';
// Responsive container with loading states
<MapContainer className="h-[500px]">
{isLoading && <MapSkeleton />}
{error && <MapError message={error} onRetry={retry} />}
{isEmpty && <MapEmpty message="No data available" />}
{data && <Map>...</Map>}
</MapContainer>;Core Map
import { Map, useMap, useMapInstance } from '@classic-homes/maps-react';
<Map
// Initial viewport
initialCenter={[-122.4, 37.8]}
initialZoom={12}
initialBearing={0}
initialPitch={0}
// Bounds fitting
bounds={bounds}
fitBoundsOptions={{ padding: 50 }}
// Style
style={lightBasicStyle}
theme="light"
// Interactivity
interactive={true}
scrollZoom={true}
dragPan={true}
// Events (see Callback Memoization section)
onClick={handleClick}
onHover={handleHover}
onMove={handleMove}
onLoad={handleLoad}
/>;
// Access map instance in child components
function MapChild() {
const { map, loaded } = useMap();
// or
const map = useMapInstance();
}Data Sources
import { Source } from '@classic-homes/maps-react';
// GeoJSON data
<Source id="features" data={geojsonFeatureCollection}>
{/* Layers go here */}
</Source>
// URL source
<Source id="remote" data="https://example.com/data.geojson">
{/* Layers go here */}
</Source>
// With clustering
<Source
id="points"
data={pointData}
cluster={true}
clusterRadius={50}
clusterMaxZoom={14}
>
{/* Layers go here */}
</Source>Layer Components
import {
FillLayer,
LineLayer,
CircleLayer,
SymbolLayer,
FillExtrusionLayer,
HeatmapLayer,
} from '@classic-homes/maps-react';
// Fill layer (polygons)
<FillLayer
id="polygons"
source="features"
fillColor="#22c55e"
fillOpacity={0.7}
fillOutlineColor="#166534"
onClick={handleClick}
onHover={handleHover}
/>
// Line layer
<LineLayer
id="lines"
source="features"
lineColor="#3b82f6"
lineWidth={2}
lineCap="round"
/>
// Circle layer (points)
<CircleLayer
id="points"
source="features"
circleRadius={6}
circleColor="#ef4444"
circleStrokeWidth={2}
circleStrokeColor="#ffffff"
/>
// Symbol layer (labels/icons)
<SymbolLayer
id="labels"
source="features"
textField={['get', 'name']}
textSize={12}
iconImage="marker"
/>
// 3D extrusions
<FillExtrusionLayer
id="buildings"
source="features"
fillExtrusionHeight={['get', 'height']}
fillExtrusionColor="#94a3b8"
fillExtrusionOpacity={0.9}
/>
// Heatmap
<HeatmapLayer
id="heat"
source="features"
heatmapWeight={1}
heatmapIntensity={0.5}
heatmapRadius={30}
/>Markers and Popups
import { Marker, Popup, PopupCard, PopupHeader, PopupContent, PopupFooter } from '@classic-homes/maps-react';
// Custom marker with popup
<Marker
lngLat={[-122.4, 37.8]}
anchor="bottom"
draggable={true}
onClick={handleClick}
onDragEnd={handleDrag}
>
<div className="custom-marker">
<MapPinIcon />
</div>
</Marker>
// Popup with card styling
<Popup lngLat={[-122.4, 37.8]} anchor="bottom" closeOnClick={false}>
<PopupCard>
<PopupHeader>
<h3>Location Name</h3>
</PopupHeader>
<PopupContent>
<p>Description goes here</p>
</PopupContent>
<PopupFooter>
<button>View Details</button>
</PopupFooter>
</PopupCard>
</Popup>Controls
import { Legend, LegendGroup, CoordinateDisplay } from '@classic-homes/maps-react';
// Legend
<Legend
position="bottom-left"
title="Status"
items={[
{ label: 'Available', color: '#22c55e' },
{ label: 'Pending', color: '#f59e0b' },
{ label: 'Sold', color: '#ef4444' },
]}
/>
// Legend group with collapsible sections
<LegendGroup
position="bottom-left"
legends={[
{
title: 'Status',
items: [{ label: 'Available', color: '#22c55e' }],
},
{
title: 'Price',
gradient: {
stops: [
{ value: 0, color: '#eff6ff', label: '$0' },
{ value: 100, color: '#1e40af', label: '$1M+' },
],
},
},
]}
/>
// Coordinate display
<CoordinateDisplay position="bottom-right" precision={4} />Deck.gl Overlay
import { DeckOverlay } from '@classic-homes/maps-react';
import { ScatterplotLayer } from '@deck.gl/layers';
<Map>
<DeckOverlay
layers={[
new ScatterplotLayer({
id: 'scatter',
data: points,
getPosition: (d) => d.coordinates,
getRadius: 100,
getFillColor: [255, 0, 0],
}),
]}
/>
</Map>;Callback Memoization
Important: Event callbacks passed to map components should be memoized to prevent unnecessary effect re-runs. Unmemoized callbacks can cause event handlers to be detached and reattached on every render.
import { useCallback, useMemo } from 'react';
function MyMap() {
// GOOD: Memoize callbacks
const handleClick = useCallback((e: MapClickEventParams) => {
console.log('Clicked:', e.lngLat);
}, []);
const handleHover = useCallback((e: MapHoverEventParams) => {
setHoveredFeature(e.features?.[0] ?? null);
}, []);
// GOOD: Memoize layer event handlers
const handleFeatureClick = useCallback((e: FeatureClickEventParams) => {
setSelectedId(e.features[0]?.id);
}, []);
return (
<Map onClick={handleClick} onHover={handleHover}>
<Source id="lots" data={data}>
<FillLayer id="lots-fill" fillColor="#22c55e" onClick={handleFeatureClick} />
</Source>
</Map>
);
}
// BAD: Inline callbacks cause re-attachment
function BadExample() {
return (
<Map
// This creates a new function every render!
onClick={(e) => console.log(e)}
>
...
</Map>
);
}Hooks
useMapTheme
import { useMapTheme } from '@classic-homes/maps-react';
function ThemedMap() {
const { theme, style, isDark, toggleTheme } = useMapTheme();
return (
<>
<Map style={style} theme={theme} />
<button onClick={toggleTheme}>{isDark ? 'Light Mode' : 'Dark Mode'}</button>
</>
);
}useReducedMotion
import { useReducedMotion } from '@classic-homes/maps-react';
function AnimatedMap() {
const prefersReducedMotion = useReducedMotion();
const flyToLocation = (lngLat) => {
map.flyTo({
center: lngLat,
duration: prefersReducedMotion ? 0 : 1000,
});
};
}useLayer
import { useLayer } from '@classic-homes/maps-react';
// Create custom layer components
function CustomLayer({ id, source, ...props }) {
const { map, loaded } = useMap();
useLayer({
map,
loaded,
id,
source,
type: 'fill',
paint: {
'fill-color': props.color,
},
onClick: props.onClick,
});
return null;
}Cluster Utilities
import {
isClusterFeature,
expandCluster,
getClusterBounds,
expandClusterToBounds,
} from '@classic-homes/maps-react';
function handleClusterClick(e: FeatureClickEventParams) {
const feature = e.features[0];
if (isClusterFeature(feature)) {
// Option 1: Zoom to expand
expandCluster(map, 'source-id', feature.id, (zoom) => {
map.flyTo({ center: e.lngLat, zoom });
});
// Option 2: Fit to cluster bounds
expandClusterToBounds(map, 'source-id', feature, (bounds) => {
map.fitBounds(bounds, { padding: 50 });
});
}
}TypeScript
All components are fully typed:
import type {
MapComponentProps,
SourceComponentProps,
FillLayerComponentProps,
MapClickEventParams,
FeatureClickEventParams,
FeatureHoverEventParams,
} from '@classic-homes/maps-react';Peer Dependencies
react>= 18.0.0react-dom>= 18.0.0maplibre-gl>= 4.0.0 || >= 5.0.0
License
MIT
