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
// 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
// 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
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
// 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
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
// 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
// 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
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
// 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
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
../constantsand../types(map-core paths) - Keep all rendering logic identical (grid aggregation, Gaussian blur, bilinear interpolation, tooltips)
- The
getColorRGB,escapeHtml,getCanvasOverlayClass,convertToMarkerData,convertToDataPointsfunctions all move as-is
// 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.tsStep 2: Verify TypeScript compiles
Run: npx tsc --noEmit --pretty 2>&1 | head -30 Expected: No errors related to map-core files
Step 3: Commit
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
// 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
// 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
// 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
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
// 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
// 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
// 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
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.
// 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
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.
// 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
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.
// 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
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.
// 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
// 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
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:
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:
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:
// 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 unchangedStep 3: Update heatmap constants to import from map-core
In src/features/heatmap/constants.ts, update:
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 unchangedStep 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
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:
// 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 unchangedStep 2: Update geofence constants to import from map-core
In src/features/geofences/constants.ts:
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:
// 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
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:
// 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
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
// 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
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
// 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
// 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
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
git add -A
git commit -m "chore: lint and format map-core migration"Summary
| Phase | Tasks | Description |
|---|---|---|
| 1 | 1-11 | Build map-core foundation (types, constants, utils, hooks, context, layers, BaseMap) |
| 2 | 12 | Migrate Heatmap feature imports to map-core |
| 3 | 13-15 | Migrate Geofences feature to use map-core components |
| 4 | 16-17 | Cleanup duplicated code, verify, lint |
After completion, adding Corrugation Heatmap requires only:
- Create data service for corrugation data
- Create page component using
<BaseMap>+<CanvasHeatmapLayer> - Zero map infrastructure code needed