Map Framework Design
Date: 2026-02-18
Goal
Extract shared map infrastructure from Geofences and Heatmap features into a reusable map-core module. Reduce duplication, enable layer composability, and make adding new map features (e.g. Corrugation Heatmap) trivial.
Current State
Both Geofences and Heatmap features independently:
- Load Google Maps API via
useGoogleMapsApihook - Initialize a Google Maps instance with identical config (center, zoom, hybrid mapType)
- Manage map event listeners manually
- Handle coordinate conversions with overlapping utility functions
Geofences already imports heatmap data for its overlay, proving the need for cross-feature layer composability.
Architecture
Three-layer abstraction inside src/features/map-core/:
Layer 1 — MapProvider & Context
// context/MapContext.ts
interface MapContextValue {
map: google.maps.Map | null;
isLoaded: boolean;
zoom: number;
bounds: google.maps.LatLngBounds | null;
center: google.maps.LatLng | null;
}MapProviderloads Google Maps API (reusesuseGoogleMapsApilogic)MapContainerrenders the map div and instantiates the mapuseMap()hook exposes map instance to any child componentuseMapEvent(event, handler)hook for unified event subscription with automatic cleanup- zoom/bounds/center auto-sync from map events into context
<MapProvider
apiKey={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}
libraries={['visualization', 'drawing']}
>
<MapContainer
defaultCenter={{ lat: -22.6557, lng: 118.1893 }}
defaultZoom={13}
mapTypeId="hybrid"
/>
<HeatmapLayer ... />
<PolygonLayer ... />
</MapProvider>Layer 2 — Layer Registry
// layers/types.ts
interface MapLayer {
id: string; // e.g. 'dust-heatmap', 'geofences'
type: 'canvas' | 'polygon' | 'marker';
enabled: boolean;
zIndex: number;
onAdd(map: google.maps.Map): void;
onRemove(): void;
onUpdate(map: google.maps.Map): void;
}// layers/useLayerRegistry.ts
interface LayerRegistry {
layers: Map<string, MapLayer>;
register(layer: MapLayer): void;
unregister(id: string): void;
setEnabled(id: string, enabled: boolean): void;
reorder(id: string, zIndex: number): void;
}Layer lifecycle follows React component mount/unmount — register on mount, unregister on unmount.
Layer 3 — Built-in Layer Components
CanvasHeatmapLayer — extracted from customOverlay.ts:
<CanvasHeatmapLayer
id="dust-heatmap"
data={MarkerData[]}
colorScheme="blue-red" | "green-red" | custom
opacity={0.7}
zoomThreshold={18}
blurRadius?: number
gridSize?: number
enabled={true}
zIndex={2}
onPointHover?: (points) => void
/>Rendering engine unchanged — grid aggregation, Gaussian blur, bilinear interpolation, zoom-aware mode switching. Only difference: color scheme and config are now props instead of hardcoded.
PolygonLayer — extracted from GeofenceMap.tsx:
<PolygonLayer
id="geofences"
polygons={PolygonData[]} // { id, points, style, editable }
editable={boolean}
selectable={boolean}
onSelect?: (id) => void
onEdit?: (id, newPoints) => void
onRightClickVertex?: (id, vertexIndex) => void
zIndex={1}
/>DrawingLayer — extracted from GeofenceMap's Drawing Manager:
<DrawingLayer
enabled={isEditMode}
drawingModes={['polygon', 'circle', 'rectangle']}
onComplete?: (type, points) => void
zIndex={10}
/>Circle/rectangle auto-converted to polygon points via existing circleToPolygonPoints and rectangleToPolygonPoints.
Composability Examples
Standalone feature page:
// Dust Heatmap page
<BaseMap>
<CanvasHeatmapLayer id="dust" data={dustData} colorScheme="blue-red" />
</BaseMap>Multi-layer page (Geofences with heatmap overlay):
<BaseMap>
<PolygonLayer id="geofences" polygons={geofences} editable={true} />
<DrawingLayer enabled={isEditMode} onComplete={handleDraw} />
<CanvasHeatmapLayer id="dust-overlay" data={heatmapData} opacity={0.5} />
</BaseMap>Combined view:
<BaseMap>
<PolygonLayer id="geofences" polygons={geofences} />
<CanvasHeatmapLayer id="dust" data={dustData} colorScheme="blue-red" />
<CanvasHeatmapLayer id="corrugation" data={corrData} colorScheme="green-red" />
</BaseMap>New Corrugation Heatmap feature — near-zero map code:
// src/features/corrugation/components/CorrugationPage.tsx
<BaseMap>
<CanvasHeatmapLayer
id="corrugation"
data={corrugationData}
colorScheme="green-red"
/>
</BaseMap>File Structure
src/features/map-core/
├── context/
│ ├── MapContext.ts # context definition + useMap() hook
│ └── MapProvider.tsx # API loading + context provider
├── components/
│ ├── MapContainer.tsx # map div rendering + instantiation
│ └── BaseMap.tsx # MapProvider + MapContainer composition
├── layers/
│ ├── types.ts # MapLayer interface
│ ├── useLayerRegistry.ts # layer registration/management hook
│ ├── CanvasHeatmapLayer.tsx # canvas heatmap (from customOverlay.ts)
│ ├── canvasRenderer.ts # rendering engine (grid, blur, interpolation)
│ ├── PolygonLayer.tsx # polygon rendering
│ └── DrawingLayer.tsx # drawing tools
├── utils/
│ ├── coordinates.ts # coordinate conversions (merged from both features)
│ ├── bounds.ts # bounding box calculation
│ └── shapes.ts # circle/rect → polygon conversion
├── constants.ts # default center, zoom, color schemes
├── types.ts # shared types (LatLngPoint, ColorScheme, etc.)
└── index.ts # public API exportsMigration Strategy
Three phases, each keeping the app fully functional:
Phase 1 — Build map-core
- Extract shared code into
src/features/map-core/ - Write unit tests for all layers and utilities
- No changes to existing features yet
Phase 2 — Migrate Heatmap
- Replace Heatmap feature's map initialization with
BaseMap+CanvasHeatmapLayer - Verify rendering parity (grid aggregation, blur, zoom-aware switching, tooltips)
- Remove duplicated code from heatmap feature
Phase 3 — Migrate Geofences
- Replace GeofenceMap with
BaseMap+PolygonLayer+DrawingLayer+CanvasHeatmapLayer - Verify all interactions (draw, edit, select, vertex delete, fly-to, heatmap overlay)
- Remove duplicated code from geofences feature
After migration
Adding Corrugation Heatmap becomes a data-only feature — fetch corrugation data, pass to CanvasHeatmapLayer, done.
Key Design Decisions
- Google Maps only — no abstraction over map providers. The project uses Google Maps exclusively, abstracting over providers adds complexity with no benefit.
- Canvas renderer stays imperative — the rendering engine (
canvasRenderer.ts) remains a class-based imperative implementation. React wrappers (CanvasHeatmapLayer.tsx) bridge the declarative/imperative boundary. - Layer lifecycle = React lifecycle — layers register on mount, unregister on unmount. No manual lifecycle management needed by feature code.
- Props over config objects — layer components use flat props for common options. Keeps JSX readable and TypeScript inference clean.
- No event bus — layers communicate through React context and props, not a custom event system. Simpler to debug and test.