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

Map Framework Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Extract shared map infrastructure from Geofences and Heatmap features into a reusable map-core module under src/features/map-core/.

Architecture: Three-layer abstraction — MapProvider/Context for Google Maps lifecycle, LayerRegistry for declarative layer management, and built-in layer components (CanvasHeatmapLayer, PolygonLayer, DrawingLayer). Each feature composes these building blocks instead of managing Google Maps directly.

Tech Stack: React 19, TypeScript 5, Google Maps JavaScript API, Vitest

Worktree: /Users/jackqin/Projects/Dashboard/.worktrees/map-framework (branch: feature/map-framework)

Design doc: docs/plans/2026-02-18-map-framework-design.md


Phase 1: Build map-core Foundation

Task 1: Shared Types

Files:

  • Create: src/features/map-core/types.ts
  • Test: src/features/map-core/types.test.ts

Step 1: Create shared type definitions

typescript
// src/features/map-core/types.ts

/** Google Maps LatLng literal — unified from geofences/types.ts and heatmap/types.ts */
export interface LatLngLiteral {
  lat: number;
  lng: number;
}

/** Point coordinate in {x: longitude, y: latitude} format (used by geofence API) */
export interface GeoPoint {
  x: number; // longitude
  y: number; // latitude
}

/** Color scheme for canvas heatmap rendering */
export type CanvasColorScheme = "blueToRed" | "greenToRed";

/** Processed marker data for canvas overlay rendering */
export interface MarkerData {
  id: string;
  latitude: number;
  longitude: number;
  assetId: string;
  displayName?: string;
  value: number;
  dateTime: string;
  timezone: string;
}

/** Shape types supported by drawing tools */
export type DrawingShapeType = "polygon" | "circle" | "rectangle";

/** Bounding box for geographic regions */
export interface BoundingBox {
  min_latitude: number;
  max_latitude: number;
  min_longitude: number;
  max_longitude: number;
}

/** Polygon data for PolygonLayer */
export interface PolygonData {
  id: string | number;
  points: LatLngLiteral[];
  fillColor?: string;
  fillOpacity?: number;
  strokeColor?: string;
  strokeOpacity?: number;
  strokeWeight?: number;
  editable?: boolean;
  clickable?: boolean;
}

/** Google Maps initialization options */
export interface MapOptions {
  center?: LatLngLiteral;
  zoom?: number;
  mapTypeId?: string;
  streetViewControl?: boolean;
  fullscreenControl?: boolean;
  mapTypeControl?: boolean;
  gestureHandling?: string;
  scrollwheel?: boolean;
}

/** Canvas heatmap overlay configuration */
export interface CanvasHeatmapConfig {
  maxIntensity: number;
  pointSize: number;
  blurRadius: number;
  gridSize: number;
  opacity: number;
  colorScheme: CanvasColorScheme;
  zoomThreshold?: number;
}

Step 2: Write type guard tests

typescript
// src/features/map-core/types.test.ts
import { describe, expect, it } from "vitest";
import type { LatLngLiteral, GeoPoint, MarkerData, PolygonData } from "./types";

describe("map-core types", () => {
  it("LatLngLiteral has correct shape", () => {
    const point: LatLngLiteral = { lat: -22.6557, lng: 118.1893 };
    expect(point.lat).toBe(-22.6557);
    expect(point.lng).toBe(118.1893);
  });

  it("GeoPoint uses x=lng, y=lat convention", () => {
    const point: GeoPoint = { x: 118.1893, y: -22.6557 };
    expect(point.x).toBe(118.1893); // longitude
    expect(point.y).toBe(-22.6557); // latitude
  });

  it("MarkerData has all required fields", () => {
    const marker: MarkerData = {
      id: "test-1",
      latitude: -22.6557,
      longitude: 118.1893,
      assetId: "asset-1",
      value: 42.5,
      dateTime: "2026-01-01T00:00:00Z",
      timezone: "AWST",
    };
    expect(marker.id).toBe("test-1");
    expect(marker.value).toBe(42.5);
  });

  it("PolygonData supports optional style properties", () => {
    const polygon: PolygonData = {
      id: 1,
      points: [
        { lat: -22.65, lng: 118.18 },
        { lat: -22.66, lng: 118.19 },
        { lat: -22.67, lng: 118.18 },
      ],
    };
    expect(polygon.fillColor).toBeUndefined();
    expect(polygon.points).toHaveLength(3);
  });
});

Step 3: Run test to verify it passes

Run: cd /Users/jackqin/Projects/Dashboard/.worktrees/map-framework && pnpm test:unit -- src/features/map-core/types.test.ts Expected: PASS

Step 4: Commit

bash
git add src/features/map-core/types.ts src/features/map-core/types.test.ts
git commit -m "feat(map-core): add shared type definitions"

Task 2: Shared Constants

Files:

  • Create: src/features/map-core/constants.ts

Step 1: Create shared constants

typescript
// src/features/map-core/constants.ts
import type { CanvasColorScheme, LatLngLiteral, MapOptions } from "./types";

/** Default map center — Pilbara region, Western Australia */
export const DEFAULT_MAP_CENTER: LatLngLiteral = {
  lat: -22.6557,
  lng: 118.1893,
};

/** Default map zoom level */
export const DEFAULT_MAP_ZOOM = 13;

/** Default Google Maps options shared across all map features */
export const DEFAULT_MAP_OPTIONS: MapOptions = {
  center: DEFAULT_MAP_CENTER,
  zoom: DEFAULT_MAP_ZOOM,
  mapTypeId: "hybrid",
  streetViewControl: false,
  fullscreenControl: true,
  mapTypeControl: true,
  gestureHandling: "greedy",
};

/** Zoom threshold: below = continuous heatmap, at/above = individual points */
export const POINT_MODE_ZOOM_THRESHOLD = 18;

/** Canvas color scheme configurations */
export const CANVAS_COLOR_SCHEMES: Record<
  CanvasColorScheme,
  {
    name: string;
    gradient: string;
    colors: Array<{ position: number; color: string }>;
  }
> = {
  blueToRed: {
    name: "Blue → Red",
    gradient:
      "linear-gradient(to right, rgb(0, 0, 255) 0%, rgb(0, 200, 255) 25%, rgb(50, 255, 50) 50%, rgb(255, 255, 0) 75%, rgb(255, 0, 0) 100%)",
    colors: [
      { position: 0, color: "rgb(0, 0, 255)" },
      { position: 0.25, color: "rgb(0, 200, 255)" },
      { position: 0.5, color: "rgb(50, 255, 50)" },
      { position: 0.75, color: "rgb(255, 255, 0)" },
      { position: 1, color: "rgb(255, 0, 0)" },
    ],
  },
  greenToRed: {
    name: "Green → Red",
    gradient:
      "linear-gradient(to right, rgb(50, 255, 50) 0%, rgb(0, 200, 255) 25%, rgb(0, 0, 255) 50%, rgb(255, 255, 0) 75%, rgb(255, 0, 0) 100%)",
    colors: [
      { position: 0, color: "rgb(50, 255, 50)" },
      { position: 0.25, color: "rgb(0, 200, 255)" },
      { position: 0.5, color: "rgb(0, 0, 255)" },
      { position: 0.75, color: "rgb(255, 255, 0)" },
      { position: 1, color: "rgb(255, 0, 0)" },
    ],
  },
};

/** Default canvas heatmap rendering parameters */
export const DEFAULT_HEATMAP_CONFIG = {
  maxIntensity: 250,
  pointSize: 3,
  blurRadius: 3,
  gridSize: 2,
  opacity: 0.8,
  colorScheme: "greenToRed" as CanvasColorScheme,
  zoomThreshold: POINT_MODE_ZOOM_THRESHOLD,
};

/** Default polygon style constants */
export const DEFAULT_POLYGON_COLORS = [
  "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4",
  "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F",
];
export const DEFAULT_FILL_OPACITY = 0.3;
export const DEFAULT_STROKE_OPACITY = 0.8;
export const DEFAULT_STROKE_WEIGHT = 2;
export const MIN_POLYGON_VERTICES = 3;
export const CIRCLE_APPROXIMATION_POINTS = 64;

Step 2: Commit

bash
git add src/features/map-core/constants.ts
git commit -m "feat(map-core): add shared map constants"

Task 3: Geometry Utilities

Files:

  • Create: src/features/map-core/utils/geometry.ts
  • Test: src/features/map-core/utils/geometry.test.ts

Step 1: Write failing tests

typescript
// src/features/map-core/utils/geometry.test.ts
import { describe, expect, it } from "vitest";
import {
  parseGeoPoints,
  geoPointsToLatLng,
  latLngToGeoPoints,
  calculateBoundingBox,
  circleToPolygonPoints,
  rectangleToPolygonPoints,
  getRandomColor,
} from "./geometry";

describe("geometry utilities", () => {
  describe("parseGeoPoints", () => {
    it("parses valid JSON points", () => {
      const json = JSON.stringify([{ x: 118.18, y: -22.65 }, { x: 118.19, y: -22.66 }]);
      const result = parseGeoPoints(json);
      expect(result).toHaveLength(2);
      expect(result[0]).toEqual({ x: 118.18, y: -22.65 });
    });

    it("returns empty array for null input", () => {
      expect(parseGeoPoints(null)).toEqual([]);
    });

    it("returns empty array for invalid JSON", () => {
      expect(parseGeoPoints("not json")).toEqual([]);
    });

    it("filters out invalid points", () => {
      const json = JSON.stringify([{ x: 118.18, y: -22.65 }, { foo: "bar" }]);
      expect(parseGeoPoints(json)).toHaveLength(1);
    });
  });

  describe("coordinate conversion", () => {
    it("converts GeoPoints to LatLng", () => {
      const points = [{ x: 118.18, y: -22.65 }];
      const result = geoPointsToLatLng(points);
      expect(result).toEqual([{ lat: -22.65, lng: 118.18 }]);
    });

    it("converts LatLng to GeoPoints", () => {
      const latLngs = [{ lat: -22.65, lng: 118.18 }];
      const result = latLngToGeoPoints(latLngs);
      expect(result).toEqual([{ x: 118.18, y: -22.65 }]);
    });
  });

  describe("calculateBoundingBox", () => {
    it("calculates correct bounding box", () => {
      const points = [
        { lat: -22.65, lng: 118.18 },
        { lat: -22.70, lng: 118.20 },
        { lat: -22.60, lng: 118.15 },
      ];
      const bbox = calculateBoundingBox(points);
      expect(bbox.min_latitude).toBe(-22.70);
      expect(bbox.max_latitude).toBe(-22.60);
      expect(bbox.min_longitude).toBe(118.15);
      expect(bbox.max_longitude).toBe(118.20);
    });
  });

  describe("circleToPolygonPoints", () => {
    it("generates correct number of points", () => {
      const center = { lat: -22.65, lng: 118.18 };
      const result = circleToPolygonPoints(center, 1000, 32);
      expect(result).toHaveLength(32);
    });

    it("generates points roughly at the correct distance", () => {
      const center = { lat: 0, lng: 0 };
      const radiusMeters = 1000;
      const result = circleToPolygonPoints(center, radiusMeters, 4);
      // First point should be roughly 1km north (lat offset ~0.009)
      expect(Math.abs(result[0].lat - 0.009)).toBeLessThan(0.001);
    });
  });

  describe("rectangleToPolygonPoints", () => {
    it("returns 4 corner points", () => {
      const result = rectangleToPolygonPoints(-22.60, -22.70, 118.20, 118.15);
      expect(result).toHaveLength(4);
      expect(result[0]).toEqual({ lat: -22.60, lng: 118.15 }); // NW
      expect(result[1]).toEqual({ lat: -22.60, lng: 118.20 }); // NE
      expect(result[2]).toEqual({ lat: -22.70, lng: 118.20 }); // SE
      expect(result[3]).toEqual({ lat: -22.70, lng: 118.15 }); // SW
    });
  });

  describe("getRandomColor", () => {
    it("returns a color from the provided array", () => {
      const colors = ["#FF0000", "#00FF00", "#0000FF"];
      const result = getRandomColor(colors);
      expect(colors).toContain(result);
    });

    it("returns fallback for empty array", () => {
      expect(getRandomColor([])).toBe("#3B82F6");
    });
  });
});

Step 2: Run test to verify it fails

Run: pnpm test:unit -- src/features/map-core/utils/geometry.test.ts Expected: FAIL — module not found

Step 3: Write implementation

typescript
// src/features/map-core/utils/geometry.ts
import { CIRCLE_APPROXIMATION_POINTS } from "../constants";
import type { BoundingBox, GeoPoint, LatLngLiteral } from "../types";

/** Parse JSON string to GeoPoint array */
export function parseGeoPoints(pointsJson: string | null): GeoPoint[] {
  if (!pointsJson) return [];
  try {
    const parsed: unknown = JSON.parse(pointsJson);
    if (!Array.isArray(parsed)) return [];
    return parsed.filter(
      (p: unknown): p is GeoPoint =>
        typeof p === "object" && p !== null && "x" in p && "y" in p
    );
  } catch {
    return [];
  }
}

/** Convert GeoPoint[] (x=lng, y=lat) to LatLngLiteral[] */
export function geoPointsToLatLng(points: GeoPoint[]): LatLngLiteral[] {
  return points.map((p) => ({ lat: p.y, lng: p.x }));
}

/** Convert LatLngLiteral[] to GeoPoint[] */
export function latLngToGeoPoints(latLngs: LatLngLiteral[]): GeoPoint[] {
  return latLngs.map((ll) => ({ x: ll.lng, y: ll.lat }));
}

/** Calculate bounding box from LatLng points */
export function calculateBoundingBox(points: LatLngLiteral[]): BoundingBox {
  const lats = points.map((p) => p.lat);
  const lngs = points.map((p) => p.lng);
  return {
    min_latitude: Math.min(...lats),
    max_latitude: Math.max(...lats),
    min_longitude: Math.min(...lngs),
    max_longitude: Math.max(...lngs),
  };
}

/** Convert a circle (center + radius) to polygon points */
export function circleToPolygonPoints(
  center: LatLngLiteral,
  radiusMeters: number,
  numPoints: number = CIRCLE_APPROXIMATION_POINTS
): LatLngLiteral[] {
  const points: LatLngLiteral[] = [];
  const earthRadius = 6371000;
  for (let i = 0; i < numPoints; i++) {
    const angle = (2 * Math.PI * i) / numPoints;
    const lat =
      center.lat +
      (radiusMeters / earthRadius) * (180 / Math.PI) * Math.cos(angle);
    const lng =
      center.lng +
      ((radiusMeters / earthRadius) * (180 / Math.PI) * Math.sin(angle)) /
        Math.cos((center.lat * Math.PI) / 180);
    points.push({ lat, lng });
  }
  return points;
}

/** Convert a rectangle (bounds) to polygon points */
export function rectangleToPolygonPoints(
  north: number,
  south: number,
  east: number,
  west: number
): LatLngLiteral[] {
  return [
    { lat: north, lng: west },
    { lat: north, lng: east },
    { lat: south, lng: east },
    { lat: south, lng: west },
  ];
}

/** Pick a random color from array */
export function getRandomColor(colors: string[]): string {
  return (
    colors[Math.floor(Math.random() * colors.length)] ?? colors[0] ?? "#3B82F6"
  );
}

Step 4: Run test to verify it passes

Run: cd /Users/jackqin/Projects/Dashboard/.worktrees/map-framework && pnpm test:unit -- src/features/map-core/utils/geometry.test.ts Expected: PASS

Step 5: Commit

bash
git add src/features/map-core/utils/
git commit -m "feat(map-core): add geometry utilities with tests"

Task 4: useGoogleMapsApi Hook (Move to map-core)

Files:

  • Create: src/features/map-core/hooks/useGoogleMapsApi.ts
  • Test: src/features/map-core/hooks/useGoogleMapsApi.test.ts

Step 1: Write test

typescript
// src/features/map-core/hooks/useGoogleMapsApi.test.ts
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { useGoogleMapsApi } from "./useGoogleMapsApi";

describe("useGoogleMapsApi", () => {
  beforeEach(() => {
    vi.restoreAllMocks();
  });

  it("returns error status when no API key provided", () => {
    const { result } = renderHook(() =>
      useGoogleMapsApi({ apiKey: undefined })
    );
    expect(result.current.status).toBe("error");
    expect(result.current.error).toContain("VITE_GOOGLE_MAPS_API_KEY");
  });

  it("returns loading status when API key is provided", () => {
    const { result } = renderHook(() =>
      useGoogleMapsApi({ apiKey: "test-key" })
    );
    expect(result.current.status).toBe("loading");
    expect(result.current.error).toBeNull();
  });
});

Step 2: Copy hook from heatmap feature

Copy src/features/heatmap/useGoogleMapsApi.ts to src/features/map-core/hooks/useGoogleMapsApi.ts — the code is identical, just the location changes.

Step 3: Run test

Run: pnpm test:unit -- src/features/map-core/hooks/useGoogleMapsApi.test.ts Expected: PASS

Step 4: Commit

bash
git add src/features/map-core/hooks/
git commit -m "feat(map-core): add useGoogleMapsApi hook"

Task 5: Canvas Heatmap Renderer (Move to map-core)

Files:

  • Create: src/features/map-core/layers/canvasRenderer.ts

Step 1: Copy and adapt customOverlay.ts

Copy src/features/heatmap/customOverlay.ts to src/features/map-core/layers/canvasRenderer.ts.

Changes needed:

  • Update imports to use ../constants and ../types (map-core paths)
  • Keep all rendering logic identical (grid aggregation, Gaussian blur, bilinear interpolation, tooltips)
  • The getColorRGB, escapeHtml, getCanvasOverlayClass, convertToMarkerData, convertToDataPoints functions all move as-is
typescript
// src/features/map-core/layers/canvasRenderer.ts
// Update only the imports at the top:
import { POINT_MODE_ZOOM_THRESHOLD } from "../constants";
import type { CanvasColorScheme, MarkerData } from "../types";

// ... rest of the file is identical to customOverlay.ts

Step 2: Verify TypeScript compiles

Run: npx tsc --noEmit --pretty 2>&1 | head -30 Expected: No errors related to map-core files

Step 3: Commit

bash
git add src/features/map-core/layers/canvasRenderer.ts
git commit -m "feat(map-core): add canvas heatmap renderer"

Task 6: MapContext and MapProvider

Files:

  • Create: src/features/map-core/context/MapContext.ts
  • Create: src/features/map-core/context/MapProvider.tsx
  • Test: src/features/map-core/context/MapProvider.test.tsx

Step 1: Create MapContext

typescript
// src/features/map-core/context/MapContext.ts
import { createContext, useContext } from "react";
import type { LatLngLiteral } from "../types";

export interface MapContextValue {
  map: google.maps.Map | null;
  isLoaded: boolean;
  zoom: number;
  bounds: google.maps.LatLngBounds | null;
  center: LatLngLiteral | null;
}

export const MapContext = createContext<MapContextValue>({
  map: null,
  isLoaded: false,
  zoom: 13,
  bounds: null,
  center: null,
});

/** Hook to access the Google Maps instance and state from MapProvider */
export function useMap(): MapContextValue {
  const context = useContext(MapContext);
  if (!context) {
    throw new Error("useMap must be used within a MapProvider");
  }
  return context;
}

Step 2: Create MapProvider

tsx
// src/features/map-core/context/MapProvider.tsx
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useGoogleMapsApi } from "../hooks/useGoogleMapsApi";
import type { LatLngLiteral, MapOptions } from "../types";
import { DEFAULT_MAP_OPTIONS } from "../constants";
import { MapContext, type MapContextValue } from "./MapContext";

interface MapProviderProps {
  children: ReactNode;
  apiKey?: string;
  libraries?: string[];
  mapOptions?: Partial<MapOptions>;
  className?: string;
  /** Optional callback when map is ready */
  onMapReady?: (map: google.maps.Map) => void;
}

export function MapProvider({
  children,
  apiKey,
  libraries = ["visualization", "drawing"],
  mapOptions,
  className = "absolute inset-0",
  onMapReady,
}: MapProviderProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const mapRef = useRef<google.maps.Map | null>(null);
  const [contextValue, setContextValue] = useState<MapContextValue>({
    map: null,
    isLoaded: false,
    zoom: mapOptions?.zoom ?? DEFAULT_MAP_OPTIONS.zoom ?? 13,
    bounds: null,
    center: (mapOptions?.center ?? DEFAULT_MAP_OPTIONS.center) as LatLngLiteral | null,
  });

  const { status, error } = useGoogleMapsApi({
    apiKey: apiKey ?? (import.meta.env["VITE_GOOGLE_MAPS_API_KEY"] as string | undefined),
    libraries,
  });

  // Initialize map
  useEffect(() => {
    if (status !== "ready" || !containerRef.current || mapRef.current) return;

    const mergedOptions = { ...DEFAULT_MAP_OPTIONS, ...mapOptions };
    const map = new google.maps.Map(containerRef.current, mergedOptions);
    mapRef.current = map;

    // Sync zoom/bounds/center to context
    map.addListener("zoom_changed", () => {
      const zoom = map.getZoom();
      if (zoom !== undefined) {
        setContextValue((prev) => ({ ...prev, zoom }));
      }
    });

    map.addListener("bounds_changed", () => {
      const bounds = map.getBounds() ?? null;
      const center = map.getCenter();
      setContextValue((prev) => ({
        ...prev,
        bounds,
        center: center ? { lat: center.lat(), lng: center.lng() } : prev.center,
      }));
    });

    setContextValue((prev) => ({ ...prev, map, isLoaded: true }));
    onMapReady?.(map);
  }, [status]);

  // Error state
  if (status === "error") {
    return (
      <div className="w-full h-full flex items-center justify-center bg-gray-50 dark:bg-slate-900 rounded-lg border border-red-200 dark:border-red-800">
        <div className="text-center p-6 max-w-md">
          <p className="text-sm font-medium text-red-800 dark:text-red-300 mb-1">
            Failed to load Google Maps
          </p>
          <p className="text-xs text-red-600 dark:text-red-400">
            {error || "Please set VITE_GOOGLE_MAPS_API_KEY in your .env.local file."}
          </p>
        </div>
      </div>
    );
  }

  return (
    <MapContext.Provider value={contextValue}>
      <div className="relative w-full h-full">
        <div ref={containerRef} className={className} />
        {status === "loading" && (
          <div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-slate-900/80">
            <p className="text-sm text-gray-500 dark:text-slate-400">Loading map…</p>
          </div>
        )}
        {children}
      </div>
    </MapContext.Provider>
  );
}

Step 3: Write test

tsx
// src/features/map-core/context/MapProvider.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MapProvider } from "./MapProvider";

// Mock useGoogleMapsApi
vi.mock("../hooks/useGoogleMapsApi", () => ({
  useGoogleMapsApi: vi.fn(() => ({ status: "error", error: "No API key" })),
}));

describe("MapProvider", () => {
  it("renders error state when API key is missing", () => {
    render(
      <MapProvider>
        <div>child</div>
      </MapProvider>
    );
    expect(screen.getByText("Failed to load Google Maps")).toBeInTheDocument();
  });
});

Step 4: Run test

Run: pnpm test:unit -- src/features/map-core/context/MapProvider.test.tsx Expected: PASS

Step 5: Commit

bash
git add src/features/map-core/context/
git commit -m "feat(map-core): add MapContext and MapProvider"

Task 7: Layer Registry

Files:

  • Create: src/features/map-core/layers/types.ts
  • Create: src/features/map-core/layers/useLayerRegistry.ts
  • Test: src/features/map-core/layers/useLayerRegistry.test.ts

Step 1: Create layer types

typescript
// src/features/map-core/layers/types.ts

/** Interface that all map layers must implement */
export interface MapLayer {
  id: string;
  type: "canvas" | "polygon" | "marker" | "drawing";
  enabled: boolean;
  zIndex: number;
  onAdd(map: google.maps.Map): void;
  onRemove(): void;
  onUpdate(map: google.maps.Map): void;
}

Step 2: Create useLayerRegistry hook

typescript
// src/features/map-core/layers/useLayerRegistry.ts
import { useCallback, useRef } from "react";
import type { MapLayer } from "./types";

export function useLayerRegistry() {
  const layersRef = useRef<Map<string, MapLayer>>(new Map());

  const register = useCallback((layer: MapLayer, map: google.maps.Map | null) => {
    const existing = layersRef.current.get(layer.id);
    if (existing) {
      existing.onRemove();
    }
    layersRef.current.set(layer.id, layer);
    if (map && layer.enabled) {
      layer.onAdd(map);
    }
  }, []);

  const unregister = useCallback((id: string) => {
    const layer = layersRef.current.get(id);
    if (layer) {
      layer.onRemove();
      layersRef.current.delete(id);
    }
  }, []);

  const setEnabled = useCallback((id: string, enabled: boolean, map: google.maps.Map | null) => {
    const layer = layersRef.current.get(id);
    if (!layer) return;
    layer.enabled = enabled;
    if (enabled && map) {
      layer.onAdd(map);
    } else {
      layer.onRemove();
    }
  }, []);

  const unregisterAll = useCallback(() => {
    for (const layer of layersRef.current.values()) {
      layer.onRemove();
    }
    layersRef.current.clear();
  }, []);

  return { register, unregister, setEnabled, unregisterAll, layers: layersRef };
}

Step 3: Write test

typescript
// src/features/map-core/layers/useLayerRegistry.test.ts
import { renderHook, act } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useLayerRegistry } from "./useLayerRegistry";
import type { MapLayer } from "./types";

const createMockLayer = (id: string): MapLayer => ({
  id,
  type: "polygon",
  enabled: true,
  zIndex: 1,
  onAdd: vi.fn(),
  onRemove: vi.fn(),
  onUpdate: vi.fn(),
});

describe("useLayerRegistry", () => {
  it("registers and calls onAdd when map is provided", () => {
    const { result } = renderHook(() => useLayerRegistry());
    const layer = createMockLayer("test");
    const mockMap = {} as google.maps.Map;

    act(() => {
      result.current.register(layer, mockMap);
    });

    expect(layer.onAdd).toHaveBeenCalledWith(mockMap);
  });

  it("unregisters and calls onRemove", () => {
    const { result } = renderHook(() => useLayerRegistry());
    const layer = createMockLayer("test");

    act(() => {
      result.current.register(layer, null);
      result.current.unregister("test");
    });

    expect(layer.onRemove).toHaveBeenCalled();
  });

  it("cleans up existing layer when re-registering same id", () => {
    const { result } = renderHook(() => useLayerRegistry());
    const layer1 = createMockLayer("test");
    const layer2 = createMockLayer("test");

    act(() => {
      result.current.register(layer1, null);
      result.current.register(layer2, null);
    });

    expect(layer1.onRemove).toHaveBeenCalled();
  });

  it("unregisterAll cleans up all layers", () => {
    const { result } = renderHook(() => useLayerRegistry());
    const layer1 = createMockLayer("a");
    const layer2 = createMockLayer("b");

    act(() => {
      result.current.register(layer1, null);
      result.current.register(layer2, null);
      result.current.unregisterAll();
    });

    expect(layer1.onRemove).toHaveBeenCalled();
    expect(layer2.onRemove).toHaveBeenCalled();
  });
});

Step 4: Run test

Run: pnpm test:unit -- src/features/map-core/layers/useLayerRegistry.test.ts Expected: PASS

Step 5: Commit

bash
git add src/features/map-core/layers/types.ts src/features/map-core/layers/useLayerRegistry.ts src/features/map-core/layers/useLayerRegistry.test.ts
git commit -m "feat(map-core): add layer registry with types and tests"

Task 8: CanvasHeatmapLayer Component

Files:

  • Create: src/features/map-core/layers/CanvasHeatmapLayer.tsx

Step 1: Create the React wrapper component

This component bridges the declarative React world with the imperative CanvasOverlay class.

tsx
// src/features/map-core/layers/CanvasHeatmapLayer.tsx
import { useEffect, useRef } from "react";
import { useMap } from "../context/MapContext";
import { DEFAULT_HEATMAP_CONFIG, POINT_MODE_ZOOM_THRESHOLD } from "../constants";
import type { CanvasColorScheme, CanvasHeatmapConfig, MarkerData } from "../types";
import {
  type CanvasOverlayInstance,
  getCanvasOverlayClass,
} from "./canvasRenderer";

interface CanvasHeatmapLayerProps {
  id: string;
  data: MarkerData[];
  config?: Partial<CanvasHeatmapConfig>;
  colorScheme?: CanvasColorScheme;
  opacity?: number;
  enabled?: boolean;
  onPointHover?: (points: MarkerData[]) => void;
}

export function CanvasHeatmapLayer({
  id,
  data,
  config,
  colorScheme,
  opacity,
  enabled = true,
}: CanvasHeatmapLayerProps) {
  const { map, isLoaded } = useMap();
  const overlayRef = useRef<CanvasOverlayInstance | null>(null);

  const mergedConfig: CanvasHeatmapConfig = {
    ...DEFAULT_HEATMAP_CONFIG,
    ...config,
    ...(colorScheme ? { colorScheme } : {}),
    ...(opacity !== undefined ? { opacity } : {}),
  };

  // Create/destroy overlay based on enabled state and data
  useEffect(() => {
    if (!isLoaded || !map) return;

    if (!enabled || data.length === 0) {
      if (overlayRef.current) {
        overlayRef.current.setMap(null);
        overlayRef.current = null;
      }
      return;
    }

    // If overlay exists, just update markers
    if (overlayRef.current) {
      overlayRef.current.updateMarkers(data);
      return;
    }

    const OverlayClass = getCanvasOverlayClass();
    const overlay = new (OverlayClass as unknown as new (
      markers: MarkerData[],
      maxIntensity: number,
      pointSize: number,
      blurRadius: number,
      gridSize: number,
      opacity: number,
      colorScheme: string
    ) => CanvasOverlayInstance)(
      data,
      mergedConfig.maxIntensity,
      mergedConfig.pointSize,
      mergedConfig.blurRadius,
      mergedConfig.gridSize,
      mergedConfig.opacity,
      mergedConfig.colorScheme
    );
    overlay.setMap(map);
    overlayRef.current = overlay;

    return () => {
      if (overlayRef.current) {
        overlayRef.current.setMap(null);
        overlayRef.current = null;
      }
    };
  }, [isLoaded, map, enabled, data]);

  // Update params when config changes
  useEffect(() => {
    if (overlayRef.current) {
      overlayRef.current.updateParams(
        mergedConfig.maxIntensity,
        mergedConfig.pointSize,
        mergedConfig.blurRadius,
        mergedConfig.gridSize,
        mergedConfig.opacity,
        mergedConfig.colorScheme
      );
    }
  }, [
    mergedConfig.maxIntensity,
    mergedConfig.pointSize,
    mergedConfig.blurRadius,
    mergedConfig.gridSize,
    mergedConfig.opacity,
    mergedConfig.colorScheme,
  ]);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (overlayRef.current) {
        overlayRef.current.setMap(null);
        overlayRef.current = null;
      }
    };
  }, []);

  return null; // Renders via Google Maps overlay, not React DOM
}

Step 2: Commit

bash
git add src/features/map-core/layers/CanvasHeatmapLayer.tsx
git commit -m "feat(map-core): add CanvasHeatmapLayer component"

Task 9: PolygonLayer Component

Files:

  • Create: src/features/map-core/layers/PolygonLayer.tsx

Step 1: Create the PolygonLayer component

Extracts polygon rendering logic from GeofenceMap.tsx into a reusable component.

tsx
// src/features/map-core/layers/PolygonLayer.tsx
import { useCallback, useEffect, useRef } from "react";
import { useMap } from "../context/MapContext";
import {
  DEFAULT_FILL_OPACITY,
  DEFAULT_STROKE_OPACITY,
  DEFAULT_STROKE_WEIGHT,
} from "../constants";
import type { LatLngLiteral, PolygonData } from "../types";

interface PolygonLayerProps {
  id: string;
  polygons: PolygonData[];
  editable?: boolean;
  selectable?: boolean;
  selectedId?: string | number | null;
  onSelect?: (id: string | number) => void;
  onEdit?: (
    id: string | number,
    newPoints: LatLngLiteral[],
  ) => void;
  onRightClickVertex?: (id: string | number, vertexIndex: number) => void;
  enabled?: boolean;
}

export function PolygonLayer({
  id,
  polygons,
  editable = false,
  selectable = true,
  selectedId,
  onSelect,
  onEdit,
  onRightClickVertex,
  enabled = true,
}: PolygonLayerProps) {
  const { map, isLoaded } = useMap();
  const polygonsRef = useRef<Map<string | number, google.maps.Polygon>>(new Map());
  const listenersRef = useRef<google.maps.MapsEventListener[]>([]);

  const clearPolygons = useCallback(() => {
    for (const listener of listenersRef.current) {
      google.maps.event.removeListener(listener);
    }
    listenersRef.current = [];
    for (const poly of polygonsRef.current.values()) {
      poly.setMap(null);
    }
    polygonsRef.current.clear();
  }, []);

  // Render polygons
  useEffect(() => {
    if (!isLoaded || !map || !enabled) {
      clearPolygons();
      return;
    }

    clearPolygons();

    for (const polyData of polygons) {
      if (polyData.points.length < 3) continue;

      const isSelected = polyData.id === selectedId;
      const fillColor = polyData.fillColor ?? "#4ECDC4";
      const fillOpacity = isSelected
        ? (polyData.fillOpacity ?? DEFAULT_FILL_OPACITY) + 0.15
        : (polyData.fillOpacity ?? DEFAULT_FILL_OPACITY);
      const strokeWeight = isSelected
        ? (polyData.strokeWeight ?? DEFAULT_STROKE_WEIGHT) + 2
        : (polyData.strokeWeight ?? DEFAULT_STROKE_WEIGHT);

      const polygon = new google.maps.Polygon({
        paths: polyData.points,
        fillColor,
        fillOpacity,
        strokeColor: polyData.strokeColor ?? fillColor,
        strokeOpacity: polyData.strokeOpacity ?? DEFAULT_STROKE_OPACITY,
        strokeWeight,
        editable: isSelected && editable,
        clickable: selectable || (polyData.clickable ?? true),
        map,
      });

      // Click to select
      if (selectable && onSelect) {
        listenersRef.current.push(
          polygon.addListener("click", () => {
            onSelect(polyData.id);
          })
        );
      }

      // Edit listeners for selected polygon
      if (isSelected && editable && onEdit) {
        const path = polygon.getPath();

        const handlePathChange = () => {
          const newPoints: LatLngLiteral[] = [];
          for (let i = 0; i < path.getLength(); i++) {
            const pt = path.getAt(i);
            newPoints.push({ lat: pt.lat(), lng: pt.lng() });
          }
          onEdit(polyData.id, newPoints);
        };

        listenersRef.current.push(
          google.maps.event.addListener(path, "set_at", handlePathChange),
          google.maps.event.addListener(path, "insert_at", handlePathChange),
          google.maps.event.addListener(path, "remove_at", handlePathChange)
        );

        // Right-click vertex to delete
        if (onRightClickVertex) {
          listenersRef.current.push(
            google.maps.event.addListener(
              polygon,
              "rightclick",
              (e: google.maps.PolyMouseEvent) => {
                if (e.vertex != null && path.getLength() > 3) {
                  path.removeAt(e.vertex);
                }
              }
            )
          );
        }
      }

      polygonsRef.current.set(polyData.id, polygon);
    }

    return () => {
      clearPolygons();
    };
  }, [isLoaded, map, enabled, polygons, selectedId, editable, selectable, onSelect, onEdit, onRightClickVertex, clearPolygons]);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      clearPolygons();
    };
  }, [clearPolygons]);

  return null;
}

Step 2: Commit

bash
git add src/features/map-core/layers/PolygonLayer.tsx
git commit -m "feat(map-core): add PolygonLayer component"

Task 10: DrawingLayer Component

Files:

  • Create: src/features/map-core/layers/DrawingLayer.tsx

Step 1: Create the DrawingLayer component

Extracts drawing manager logic from GeofenceMap.tsx.

tsx
// src/features/map-core/layers/DrawingLayer.tsx
import { useEffect, useRef } from "react";
import { useMap } from "../context/MapContext";
import {
  DEFAULT_FILL_OPACITY,
  DEFAULT_STROKE_WEIGHT,
  MIN_POLYGON_VERTICES,
} from "../constants";
import type { DrawingShapeType, LatLngLiteral } from "../types";
import { circleToPolygonPoints, rectangleToPolygonPoints } from "../utils/geometry";

interface DrawingLayerProps {
  enabled?: boolean;
  drawingModes?: DrawingShapeType[];
  defaultColor?: string;
  onComplete?: (type: DrawingShapeType, points: LatLngLiteral[]) => void;
}

export function DrawingLayer({
  enabled = false,
  drawingModes = ["polygon", "circle", "rectangle"],
  defaultColor = "#4ECDC4",
  onComplete,
}: DrawingLayerProps) {
  const { map, isLoaded } = useMap();
  const drawingManagerRef = useRef<google.maps.drawing.DrawingManager | null>(null);

  useEffect(() => {
    if (!isLoaded || !map) return;

    // Remove existing
    if (drawingManagerRef.current) {
      drawingManagerRef.current.setMap(null);
      drawingManagerRef.current = null;
    }

    if (!enabled) return;

    const gm = google.maps;
    const modeMap: Record<DrawingShapeType, google.maps.drawing.OverlayType> = {
      polygon: gm.drawing.OverlayType.POLYGON,
      circle: gm.drawing.OverlayType.CIRCLE,
      rectangle: gm.drawing.OverlayType.RECTANGLE,
    };

    const dm = new gm.drawing.DrawingManager({
      drawingMode: null,
      drawingControl: true,
      drawingControlOptions: {
        position: gm.ControlPosition.TOP_CENTER,
        drawingModes: drawingModes.map((m) => modeMap[m]),
      },
      polygonOptions: {
        fillColor: defaultColor,
        fillOpacity: DEFAULT_FILL_OPACITY,
        strokeWeight: DEFAULT_STROKE_WEIGHT,
        strokeColor: defaultColor,
        editable: false,
      },
      circleOptions: {
        fillColor: defaultColor,
        fillOpacity: DEFAULT_FILL_OPACITY,
        strokeWeight: DEFAULT_STROKE_WEIGHT,
        strokeColor: defaultColor,
      },
      rectangleOptions: {
        fillColor: defaultColor,
        fillOpacity: DEFAULT_FILL_OPACITY,
        strokeWeight: DEFAULT_STROKE_WEIGHT,
        strokeColor: defaultColor,
      },
    });

    dm.setMap(map);
    drawingManagerRef.current = dm;

    const overlayListener = gm.event.addListener(
      dm,
      "overlaycomplete",
      (event: google.maps.drawing.OverlayCompleteEvent) => {
        let latLngs: LatLngLiteral[] = [];
        let shapeType: DrawingShapeType = "polygon";

        if (
          event.type === gm.drawing.OverlayType.POLYGON &&
          event.overlay instanceof gm.Polygon
        ) {
          shapeType = "polygon";
          const path = event.overlay.getPath();
          for (let i = 0; i < path.getLength(); i++) {
            const pt = path.getAt(i);
            latLngs.push({ lat: pt.lat(), lng: pt.lng() });
          }
        } else if (
          event.type === gm.drawing.OverlayType.CIRCLE &&
          event.overlay instanceof gm.Circle
        ) {
          shapeType = "circle";
          const center = event.overlay.getCenter();
          const radius = event.overlay.getRadius();
          if (center) {
            latLngs = circleToPolygonPoints(
              { lat: center.lat(), lng: center.lng() },
              radius
            );
          }
        } else if (
          event.type === gm.drawing.OverlayType.RECTANGLE &&
          event.overlay instanceof gm.Rectangle
        ) {
          shapeType = "rectangle";
          const bounds = event.overlay.getBounds();
          if (bounds) {
            const ne = bounds.getNorthEast();
            const sw = bounds.getSouthWest();
            latLngs = rectangleToPolygonPoints(
              ne.lat(), sw.lat(), ne.lng(), sw.lng()
            );
          }
        }

        // Remove drawn overlay — the saved data will render from PolygonLayer
        event.overlay.setMap(null);

        if (latLngs.length < MIN_POLYGON_VERTICES) return;

        onComplete?.(shapeType, latLngs);
        dm.setDrawingMode(null);
      }
    );

    return () => {
      gm.event.removeListener(overlayListener);
      dm.setMap(null);
      drawingManagerRef.current = null;
    };
  }, [isLoaded, map, enabled, drawingModes, defaultColor, onComplete]);

  return null;
}

Step 2: Commit

bash
git add src/features/map-core/layers/DrawingLayer.tsx
git commit -m "feat(map-core): add DrawingLayer component"

Task 11: BaseMap Convenience Component & Public API

Files:

  • Create: src/features/map-core/components/BaseMap.tsx
  • Create: src/features/map-core/index.ts

Step 1: Create BaseMap

A convenience wrapper that combines MapProvider with common patterns.

tsx
// src/features/map-core/components/BaseMap.tsx
import type { ReactNode } from "react";
import { MapProvider } from "../context/MapProvider";
import type { MapOptions } from "../types";

interface BaseMapProps {
  children?: ReactNode;
  apiKey?: string;
  libraries?: string[];
  mapOptions?: Partial<MapOptions>;
  className?: string;
  onMapReady?: (map: google.maps.Map) => void;
}

export function BaseMap({
  children,
  apiKey,
  libraries,
  mapOptions,
  className = "w-full h-full",
  onMapReady,
}: BaseMapProps) {
  return (
    <div className={className}>
      <MapProvider
        apiKey={apiKey}
        libraries={libraries}
        mapOptions={mapOptions}
        onMapReady={onMapReady}
      >
        {children}
      </MapProvider>
    </div>
  );
}

Step 2: Create public API barrel export

typescript
// src/features/map-core/index.ts

// Components
export { BaseMap } from "./components/BaseMap";
export { MapProvider } from "./context/MapProvider";

// Context & Hooks
export { MapContext, useMap } from "./context/MapContext";
export { useGoogleMapsApi } from "./hooks/useGoogleMapsApi";

// Layer Components
export { CanvasHeatmapLayer } from "./layers/CanvasHeatmapLayer";
export { PolygonLayer } from "./layers/PolygonLayer";
export { DrawingLayer } from "./layers/DrawingLayer";

// Layer Infrastructure
export { useLayerRegistry } from "./layers/useLayerRegistry";
export type { MapLayer } from "./layers/types";

// Canvas Renderer (for advanced usage)
export {
  getCanvasOverlayClass,
  convertToMarkerData,
  convertToDataPoints,
  type CanvasOverlayInstance,
  type DataPoint,
} from "./layers/canvasRenderer";

// Types
export type {
  LatLngLiteral,
  GeoPoint,
  CanvasColorScheme,
  MarkerData,
  DrawingShapeType,
  BoundingBox,
  PolygonData,
  MapOptions,
  CanvasHeatmapConfig,
} from "./types";

// Constants
export {
  DEFAULT_MAP_CENTER,
  DEFAULT_MAP_ZOOM,
  DEFAULT_MAP_OPTIONS,
  POINT_MODE_ZOOM_THRESHOLD,
  CANVAS_COLOR_SCHEMES,
  DEFAULT_HEATMAP_CONFIG,
  DEFAULT_POLYGON_COLORS,
  DEFAULT_FILL_OPACITY,
  DEFAULT_STROKE_OPACITY,
  DEFAULT_STROKE_WEIGHT,
  MIN_POLYGON_VERTICES,
  CIRCLE_APPROXIMATION_POINTS,
} from "./constants";

// Geometry Utilities
export {
  parseGeoPoints,
  geoPointsToLatLng,
  latLngToGeoPoints,
  calculateBoundingBox,
  circleToPolygonPoints,
  rectangleToPolygonPoints,
  getRandomColor,
} from "./utils/geometry";

Step 3: Verify TypeScript compiles

Run: npx tsc --noEmit 2>&1 | grep "map-core" | head -20 Expected: No errors from map-core files

Step 4: Commit

bash
git add src/features/map-core/components/ src/features/map-core/index.ts
git commit -m "feat(map-core): add BaseMap component and public API exports"

Phase 2: Migrate Heatmap Feature

Task 12: Update Heatmap Page Imports

Files:

  • Modify: src/app/(admin)/(pages)/heatmap/index.tsx

Step 1: Replace heatmap imports with map-core imports

In src/app/(admin)/(pages)/heatmap/index.tsx, update the following imports:

Replace:

typescript
import { useGoogleMapsApi } from "@/features/heatmap/useGoogleMapsApi";
import {
  type CanvasOverlayInstance,
  convertToMarkerData,
  getCanvasOverlayClass,
} from "@/features/heatmap/customOverlay";
import {
  CANVAS_COLOR_SCHEMES,
  DEFAULT_CONTROLS,
  DEFAULT_MAP_CENTER,
  MINE_SITE_FALLBACK_COORDINATES,
  POINT_MODE_ZOOM_THRESHOLD,
} from "@/features/heatmap/constants";
import type {
  HeatmapControlsState,
  HeatmapRecord,
  MarkerData,
} from "@/features/heatmap/types";

With:

typescript
import {
  useGoogleMapsApi,
  type CanvasOverlayInstance,
  convertToMarkerData,
  getCanvasOverlayClass,
  CANVAS_COLOR_SCHEMES,
  POINT_MODE_ZOOM_THRESHOLD,
  DEFAULT_MAP_CENTER,
  type CanvasColorScheme,
  type MarkerData,
} from "@/features/map-core";
// Keep feature-specific imports
import {
  DEFAULT_CONTROLS,
  MINE_SITE_FALLBACK_COORDINATES,
} from "@/features/heatmap/constants";
import type {
  HeatmapControlsState,
  HeatmapRecord,
} from "@/features/heatmap/types";

Step 2: Update heatmap feature types to re-export from map-core

In src/features/heatmap/types.ts, update:

typescript
// Re-export shared types from map-core
export type { CanvasColorScheme, MarkerData } from "@/features/map-core";

// Keep feature-specific types
export type ValueFilterMode = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "range";

export type HeatmapRecord = {
  Location_Latitude: string;
  Location_Longitude: string;
  Location_AssetValue: string;
  Location_DateTimeUtc?: string;
  Location_TimeZoneShortName?: string;
  Asset_AssetId?: string;
  Location_AssetLocationDataId?: string;
  [key: string]: unknown;
};

// ... rest of feature-specific types unchanged

Step 3: Update heatmap constants to import from map-core

In src/features/heatmap/constants.ts, update:

typescript
import {
  CANVAS_COLOR_SCHEMES as SHARED_CANVAS_COLOR_SCHEMES,
  DEFAULT_MAP_CENTER as SHARED_DEFAULT_MAP_CENTER,
  POINT_MODE_ZOOM_THRESHOLD as SHARED_POINT_MODE_ZOOM_THRESHOLD,
} from "@/features/map-core";
import type { HeatmapControlsState } from "./types";

// Re-export shared constants for backward compatibility
export const DEFAULT_MAP_CENTER = SHARED_DEFAULT_MAP_CENTER;
export const POINT_MODE_ZOOM_THRESHOLD = SHARED_POINT_MODE_ZOOM_THRESHOLD;
export const CANVAS_COLOR_SCHEMES = SHARED_CANVAS_COLOR_SCHEMES;

// Keep feature-specific constants
export const MINE_SITE_FALLBACK_COORDINATES: Record<string, { lat: number; lng: number }> = {
  Chile: { lat: -23.6509, lng: -70.3975 },
  Marandoo: { lat: -22.6557, lng: 118.1893 },
  "Gudai Darri": { lat: -22.55, lng: 119.14 },
};

// ... rest of feature-specific constants unchanged

Step 4: Verify build

Run: cd /Users/jackqin/Projects/Dashboard/.worktrees/map-framework && pnpm build 2>&1 | tail -10 Expected: Build succeeds

Step 5: Run existing tests

Run: pnpm test:unit 2>&1 | tail -10 Expected: Same pass/fail as baseline (228 passing, 3 pre-existing failures)

Step 6: Commit

bash
git add -A
git commit -m "refactor(heatmap): migrate imports to map-core"

Phase 3: Migrate Geofences Feature

Task 13: Update Geofence Imports

Files:

  • Modify: src/features/geofences/types.ts
  • Modify: src/features/geofences/constants.ts
  • Modify: src/features/geofences/utils.ts

Step 1: Update geofence types to re-export from map-core

In src/features/geofences/types.ts:

typescript
// Re-export shared types from map-core
export type { LatLngLiteral as LatLngPoint, GeoPoint as GeofencePoint } from "@/features/map-core";

// Keep feature-specific types unchanged
export interface GeofenceGroup { /* ... unchanged ... */ }
export interface Geofence { /* ... unchanged ... */ }
export type GeofenceShapeType = "polygon" | "circle" | "rectangle";
// ... rest unchanged

Step 2: Update geofence constants to import from map-core

In src/features/geofences/constants.ts:

typescript
import {
  DEFAULT_MAP_CENTER as SHARED_MAP_CENTER,
  DEFAULT_MAP_ZOOM as SHARED_MAP_ZOOM,
  DEFAULT_POLYGON_COLORS,
  DEFAULT_FILL_OPACITY as SHARED_FILL_OPACITY,
  DEFAULT_STROKE_OPACITY as SHARED_STROKE_OPACITY,
  DEFAULT_STROKE_WEIGHT as SHARED_STROKE_WEIGHT,
  MIN_POLYGON_VERTICES as SHARED_MIN_VERTICES,
  CIRCLE_APPROXIMATION_POINTS as SHARED_CIRCLE_POINTS,
} from "@/features/map-core";

// Re-export for backward compatibility
export const DEFAULT_GEOFENCE_COLORS = DEFAULT_POLYGON_COLORS;
export const DEFAULT_FILL_OPACITY = SHARED_FILL_OPACITY;
export const DEFAULT_STROKE_OPACITY = SHARED_STROKE_OPACITY;
export const DEFAULT_STROKE_WEIGHT = SHARED_STROKE_WEIGHT;
export const MIN_POLYGON_VERTICES = SHARED_MIN_VERTICES;
export const CIRCLE_APPROXIMATION_POINTS = SHARED_CIRCLE_POINTS;
export const DEFAULT_MAP_CENTER = SHARED_MAP_CENTER;
export const DEFAULT_MAP_ZOOM = SHARED_MAP_ZOOM;

/** Custom geofence group name for manually created geofences */
export const CUSTOM_GROUP_NAME = "Custom";

Step 3: Update geofence utils to import from map-core

In src/features/geofences/utils.ts:

typescript
// Re-export geometry utilities from map-core with backward-compatible names
export {
  parseGeoPoints as parsePoints,
  geoPointsToLatLng as pointsToLatLng,
  latLngToGeoPoints as latLngToPoints,
  calculateBoundingBox,
  circleToPolygonPoints,
  rectangleToPolygonPoints,
  getRandomColor,
} from "@/features/map-core";

/** Generate a new UUID for manually created geofences */
export function generateGeofenceId(): string {
  return crypto.randomUUID();
}

Step 4: Verify build

Run: pnpm build 2>&1 | tail -10 Expected: Build succeeds

Step 5: Run tests

Run: pnpm test:unit 2>&1 | tail -10 Expected: Same pass/fail as baseline

Step 6: Commit

bash
git add -A
git commit -m "refactor(geofences): migrate types, constants, utils to map-core"

Task 14: Refactor GeofenceMap to Use map-core Components

Files:

  • Modify: src/features/geofences/components/GeofenceMap.tsx

Step 1: Rewrite GeofenceMap using BaseMap + layer components

Replace the entire GeofenceMap.tsx with a composition of map-core components:

tsx
// src/features/geofences/components/GeofenceMap.tsx
import { useCallback, useEffect, useMemo } from "react";
import {
  BaseMap,
  useMap,
  CanvasHeatmapLayer,
  PolygonLayer,
  DrawingLayer,
  convertToMarkerData,
  DEFAULT_HEATMAP_CONFIG,
  calculateBoundingBox,
  latLngToGeoPoints,
  parseGeoPoints,
  geoPointsToLatLng,
  getRandomColor,
  type LatLngLiteral,
  type PolygonData,
} from "@/features/map-core";
import type { HeatmapRecord } from "@/features/heatmap/types";
import { DEFAULT_GEOFENCE_COLORS, CUSTOM_GROUP_NAME } from "../constants";
import type {
  CreateGeofenceInput,
  Geofence,
  GeofencePageMode,
} from "../types";

interface GeofenceMapProps {
  geofences: Geofence[];
  selectedGeofenceId: number | null;
  onSelectGeofence: (id: number | null) => void;
  onCreateGeofence: (input: CreateGeofenceInput) => Promise<Geofence>;
  onUpdateGeofencePoints: (
    id: number,
    points: string,
    pointCount: number,
    bbox: {
      min_latitude: number;
      max_latitude: number;
      min_longitude: number;
      max_longitude: number;
    }
  ) => Promise<void>;
  pageMode: GeofencePageMode;
  heatmapData: HeatmapRecord[];
  heatmapEnabled: boolean;
  heatmapOpacity: number;
  mineSiteId: string | null;
  siteName: string;
}

/** Inner component that has access to MapContext */
function GeofenceMapContent({
  geofences,
  selectedGeofenceId,
  onSelectGeofence,
  onCreateGeofence,
  onUpdateGeofencePoints,
  pageMode,
  heatmapData,
  heatmapEnabled,
  heatmapOpacity,
  mineSiteId,
  siteName,
}: GeofenceMapProps) {
  const { map } = useMap();

  // Convert geofences to PolygonData
  const polygonData: PolygonData[] = useMemo(() => {
    return geofences
      .filter((g) => g.points && !g.deleted)
      .map((g) => {
        const parsed = parseGeoPoints(g.points);
        const points = geoPointsToLatLng(parsed);
        if (points.length < 3) return null;
        return {
          id: g.id,
          points,
          fillColor: g.map_fill_colour || g.colour_code || "#4ECDC4",
          editable: g.id === selectedGeofenceId && pageMode === "edit",
          clickable: true,
        };
      })
      .filter(Boolean) as PolygonData[];
  }, [geofences, selectedGeofenceId, pageMode]);

  // Convert heatmap records to MarkerData
  const heatmapMarkers = useMemo(() => {
    if (!heatmapData.length) return [];
    return convertToMarkerData(heatmapData);
  }, [heatmapData]);

  // Handle polygon edit
  const handlePolygonEdit = useCallback(
    (id: string | number, newPoints: LatLngLiteral[]) => {
      const geoPoints = latLngToGeoPoints(newPoints);
      const bbox = calculateBoundingBox(newPoints);
      void onUpdateGeofencePoints(
        id as number,
        JSON.stringify(geoPoints),
        geoPoints.length,
        bbox
      );
    },
    [onUpdateGeofencePoints]
  );

  // Handle drawing complete
  const handleDrawingComplete = useCallback(
    (_type: string, points: LatLngLiteral[]) => {
      const geoPoints = latLngToGeoPoints(points);
      const bbox = calculateBoundingBox(points);
      const color = getRandomColor(DEFAULT_GEOFENCE_COLORS);

      void onCreateGeofence({
        mine_site_id: mineSiteId,
        site_name: siteName,
        geofence_name: "New Geofence",
        points: JSON.stringify(geoPoints),
        point_count: geoPoints.length,
        ...bbox,
        map_fill_colour: color,
        colour_code: color,
        display_on_map: true,
        is_enabled: true,
      });
    },
    [mineSiteId, siteName, onCreateGeofence]
  );

  // Fly-to selected geofence
  useEffect(() => {
    if (!map || selectedGeofenceId == null) return;

    const geofence = geofences.find((g) => g.id === selectedGeofenceId);
    if (!geofence?.points) return;

    const parsed = parseGeoPoints(geofence.points);
    if (parsed.length === 0) return;

    const latLngs = geoPointsToLatLng(parsed);
    const bounds = new google.maps.LatLngBounds();
    for (const ll of latLngs) {
      bounds.extend(ll);
    }
    map.fitBounds(bounds, 60);
  }, [map, selectedGeofenceId, geofences]);

  return (
    <>
      <PolygonLayer
        id="geofences"
        polygons={polygonData}
        editable={pageMode === "edit"}
        selectable={true}
        selectedId={selectedGeofenceId}
        onSelect={(id) => onSelectGeofence(id as number)}
        onEdit={handlePolygonEdit}
        onRightClickVertex={() => {}}
      />
      <DrawingLayer
        enabled={pageMode === "edit"}
        onComplete={handleDrawingComplete}
      />
      <CanvasHeatmapLayer
        id="geofence-heatmap-overlay"
        data={heatmapMarkers}
        enabled={heatmapEnabled}
        opacity={heatmapOpacity}
      />
    </>
  );
}

export default function GeofenceMap(props: GeofenceMapProps) {
  return (
    <div className="flex-1 relative overflow-hidden bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 min-h-0">
      <BaseMap className="absolute inset-0">
        <GeofenceMapContent {...props} />
      </BaseMap>
    </div>
  );
}

Step 2: Verify build

Run: pnpm build 2>&1 | tail -10 Expected: Build succeeds

Step 3: Run tests

Run: pnpm test:unit 2>&1 | tail -10 Expected: Same pass/fail as baseline

Step 4: Commit

bash
git add -A
git commit -m "refactor(geofences): rewrite GeofenceMap using map-core components"

Task 15: Update useGeofenceHeatmap Hook

Files:

  • Modify: src/features/geofences/hooks/useGeofenceHeatmap.ts

Step 1: Update imports

typescript
// src/features/geofences/hooks/useGeofenceHeatmap.ts
import { useCallback, useState } from "react";
import { fetchHeatmapData } from "@/features/heatmap/services/databaseService";
import type { HeatmapRecord } from "@/features/heatmap/types";

// Hook implementation stays identical — it's already feature-specific
export function useGeofenceHeatmap() {
  // ... unchanged
}

No changes needed to the hook body — it's already clean. Just verify it still works.

Step 2: Verify build

Run: pnpm build 2>&1 | tail -10 Expected: Build succeeds

Step 3: Commit

bash
git add -A
git commit -m "refactor(geofences): verify useGeofenceHeatmap compatibility"

Phase 4: Cleanup & Documentation

Task 16: Remove Duplicated Code

Files:

  • Modify: src/features/heatmap/useGoogleMapsApi.ts — replace with re-export
  • Modify: src/features/heatmap/customOverlay.ts — replace with re-export

Step 1: Replace heatmap useGoogleMapsApi with re-export

typescript
// src/features/heatmap/useGoogleMapsApi.ts
// Re-export from map-core for backward compatibility
export { useGoogleMapsApi } from "@/features/map-core";

Step 2: Replace heatmap customOverlay with re-export

typescript
// src/features/heatmap/customOverlay.ts
// Re-export from map-core for backward compatibility
export {
  getCanvasOverlayClass,
  convertToMarkerData,
  convertToDataPoints,
  type CanvasOverlayInstance,
  type DataPoint,
} from "@/features/map-core";

Step 3: Verify build

Run: pnpm build 2>&1 | tail -10 Expected: Build succeeds

Step 4: Run all tests

Run: pnpm test:unit 2>&1 | tail -10 Expected: Same pass/fail as baseline

Step 5: Commit

bash
git add -A
git commit -m "refactor: replace duplicated code with map-core re-exports"

Task 17: Final Verification & Lint

Step 1: Run full build

Run: pnpm build Expected: Build succeeds

Step 2: Run all unit tests

Run: pnpm test:unit Expected: 228+ tests passing (original + new map-core tests)

Step 3: Run lint

Run: pnpm lint Expected: No new warnings/errors

Step 4: Fix any lint issues

Run: pnpm lint:fix && pnpm format

Step 5: Final commit

bash
git add -A
git commit -m "chore: lint and format map-core migration"

Summary

PhaseTasksDescription
11-11Build map-core foundation (types, constants, utils, hooks, context, layers, BaseMap)
212Migrate Heatmap feature imports to map-core
313-15Migrate Geofences feature to use map-core components
416-17Cleanup duplicated code, verify, lint

After completion, adding Corrugation Heatmap requires only:

  1. Create data service for corrugation data
  2. Create page component using <BaseMap> + <CanvasHeatmapLayer>
  3. Zero map infrastructure code needed