{{ theme.skipToContentLabel || 'Skip to content' }}

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 useGoogleMapsApi hook
  • 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

typescript
// context/MapContext.ts
interface MapContextValue {
  map: google.maps.Map | null;
  isLoaded: boolean;
  zoom: number;
  bounds: google.maps.LatLngBounds | null;
  center: google.maps.LatLng | null;
}
  • MapProvider loads Google Maps API (reuses useGoogleMapsApi logic)
  • MapContainer renders the map div and instantiates the map
  • useMap() hook exposes map instance to any child component
  • useMapEvent(event, handler) hook for unified event subscription with automatic cleanup
  • zoom/bounds/center auto-sync from map events into context
tsx
<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

typescript
// 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;
}
typescript
// 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:

tsx
<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:

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:

tsx
<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:

tsx
// Dust Heatmap page
<BaseMap>
  <CanvasHeatmapLayer id="dust" data={dustData} colorScheme="blue-red" />
</BaseMap>

Multi-layer page (Geofences with heatmap overlay):

tsx
<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:

tsx
<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:

tsx
// 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 exports

Migration 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

  1. Google Maps only — no abstraction over map providers. The project uses Google Maps exclusively, abstracting over providers adds complexity with no benefit.
  2. Canvas renderer stays imperative — the rendering engine (canvasRenderer.ts) remains a class-based imperative implementation. React wrappers (CanvasHeatmapLayer.tsx) bridge the declarative/imperative boundary.
  3. Layer lifecycle = React lifecycle — layers register on mount, unregister on unmount. No manual lifecycle management needed by feature code.
  4. Props over config objects — layer components use flat props for common options. Keeps JSX readable and TypeScript inference clean.
  5. No event bus — layers communicate through React context and props, not a custom event system. Simpler to debug and test.