fix: rewrite Google Maps traffic layer to avoid L-instance constructor error

Replace static import + createLayerComponent approach with dynamic import()
inside a useEffect, which ensures leaflet.gridlayer.googlemutant augments the
same L instance that's active at runtime. Add loading=async to Maps JS script.

Traffic is now toggled via a dedicated button (green when active) rather than
LayersControl, bypassing the react-leaflet layer lifecycle that caused the
constructor conflict.
This commit is contained in:
Logan
2026-05-25 13:59:47 -04:00
parent 96bba45ffa
commit a6d841b280
+64 -30
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
FeatureGroup, FeatureGroup,
LayersControl, LayersControl,
@@ -10,9 +10,7 @@ import {
TileLayer, TileLayer,
useMap, useMap,
} from "react-leaflet"; } from "react-leaflet";
import { createLayerComponent } from "@react-leaflet/core";
import L from "leaflet"; import L from "leaflet";
import "leaflet.gridlayer.googlemutant";
import type { CallRecord, IncidentRecord, NodeRecord } from "@/lib/types"; import type { CallRecord, IncidentRecord, NodeRecord } from "@/lib/types";
// ── Leaflet icon fix ────────────────────────────────────────────────────────── // ── Leaflet icon fix ──────────────────────────────────────────────────────────
@@ -148,17 +146,53 @@ function computeGroups<T extends { id: string; lat: number; lng: number }>(
} }
// ── Google Maps traffic layer via leaflet-google-mutant ─────────────────────── // ── Google Maps traffic layer via leaflet-google-mutant ───────────────────────
// Uses dynamic import so the side-effect augments the same L instance at runtime,
// avoiding the "GoogleMutant is not a constructor" error from static top-level imports.
function GoogleTrafficManager({ enabled }: { enabled: boolean }) {
const map = useMap();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const GoogleTrafficOverlay = createLayerComponent<any, object>( const layerRef = useRef<any>(null);
(_props, ctx) => { const scriptLoadedRef = useRef(false);
// leaflet-google-mutant augments L.gridLayer after the side-effect import
useEffect(() => {
if (!enabled) {
if (layerRef.current) { map.removeLayer(layerRef.current); layerRef.current = null; }
return;
}
const key = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
if (!key) return;
function mountLayer() {
import("leaflet.gridlayer.googlemutant").then(() => {
if (layerRef.current) return; // already mounted
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const instance = (L as any).gridLayer.googleMutant({ type: "roadmap" }); const layer = (L as any).gridLayer.googleMutant({ type: "roadmap" });
instance.addGoogleLayer("TrafficLayer"); layer.addGoogleLayer("TrafficLayer");
return { instance, context: ctx }; map.addLayer(layer);
}, layerRef.current = layer;
() => {} });
); }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((window as any).google?.maps) {
mountLayer();
} else if (!scriptLoadedRef.current) {
scriptLoadedRef.current = true;
const script = document.createElement("script");
script.src = `https://maps.googleapis.com/maps/api/js?key=${key}&loading=async`;
script.async = true;
script.onload = mountLayer;
document.head.appendChild(script);
}
return () => {
if (layerRef.current) { map.removeLayer(layerRef.current); layerRef.current = null; }
};
}, [enabled, map]);
return null;
}
// ── MapRefCapture — exposes L.Map instance to parent ───────────────────────── // ── MapRefCapture — exposes L.Map instance to parent ─────────────────────────
function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) { function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) {
@@ -357,20 +391,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
// Load Google Maps JS API for leaflet-google-mutant traffic layer const [trafficEnabled, setTrafficEnabled] = useState(false);
const [googleReady, setGoogleReady] = useState(false);
useEffect(() => {
const key = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
if (!key) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((window as any).google?.maps) { setGoogleReady(true); return; }
if (document.querySelector('script[src*="maps.googleapis.com/maps/api/js"]')) return;
const script = document.createElement("script");
script.src = `https://maps.googleapis.com/maps/api/js?key=${key}`;
script.async = true;
script.onload = () => setGoogleReady(true);
document.head.appendChild(script);
}, []);
// Live clock for TOC situational awareness // Live clock for TOC situational awareness
useEffect(() => { useEffect(() => {
@@ -468,10 +489,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
</FeatureGroup> </FeatureGroup>
</LayersControl.Overlay> </LayersControl.Overlay>
{/* Overlay: Traffic — Google Maps via leaflet-google-mutant */} {/* Traffic managed outside LayersControl to avoid L-instance conflicts */}
<LayersControl.Overlay name="Traffic"> <GoogleTrafficManager enabled={trafficEnabled} />
{googleReady ? <GoogleTrafficOverlay /> : <FeatureGroup />}
</LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */} {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
<LayersControl.Overlay name="Weather Radar"> <LayersControl.Overlay name="Weather Radar">
@@ -504,6 +523,21 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
</div> </div>
)} )}
{/* ── Traffic toggle ───────────────────────────────────────────────────── */}
{process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY && (
<button
onClick={() => setTrafficEnabled((v) => !v)}
title="Toggle traffic layer"
className={`absolute bottom-[6.5rem] right-3 z-[1001] w-8 h-8 border rounded text-xs font-bold leading-none transition-colors flex items-center justify-center select-none ${
trafficEnabled
? "bg-green-700 border-green-500 text-white"
: "bg-gray-950/90 border-gray-700 text-gray-400 hover:bg-gray-800"
}`}
>
🚦
</button>
)}
{/* ── Auto-fit button ──────────────────────────────────────────────────── */} {/* ── Auto-fit button ──────────────────────────────────────────────────── */}
{mapInstance && allPositions.length > 0 && ( {mapInstance && allPositions.length > 0 && (
<button <button