import { useEffect, useRef, useState } from 'react'; import { loadModules, setDefaultOptions } from 'esri-loader'; import type EsriConfigModule from '@arcgis/core/config.js'; import type CircleConstructor from '@arcgis/core/geometry/Circle.js'; import type GraphicConstructor from '@arcgis/core/Graphic.js'; import type GraphicsLayerConstructor from '@arcgis/core/layers/GraphicsLayer.js'; import type EsriMapConstructor from '@arcgis/core/Map.js'; import type MapViewConstructor from '@arcgis/core/views/MapView.js'; import { JobMapProps, LiveStatus, liveStatusToMapPoint, MapPoint, pointDetailRows, UTILITY_COLORS } from '../types'; const API_KEY = process.env.NEXT_PUBLIC_ARCGIS_API_KEY || ''; const LIVE_COLOR = '#1a73e8'; const DEFAULT_CENTER: [number, number] = [-98.35, 39.5]; // continental US, [lng, lat] const DEFAULT_ZOOM = 4; // Keep in sync with @arcgis/core's version in package.json (major.minor — // js.arcgis.com is keyed by major.minor only, the full patch version 404s). const CDN_VERSION = '4.34'; // Loaded via Esri's CDN (esri-loader), not bundled through webpack: the SDK // is large enough that having Next's dev-mode compiler process it — full // inline source maps for every vendored module — pegged CPU/memory badly // enough to OOM the whole host on a first compile. The browser parses/runs // the CDN script directly, so the dev server never touches it. @arcgis/core // stays a devDependency purely for its .d.ts types (import type below is // fully erased, never bundled or evaluated at runtime). setDefaultOptions({ version: CDN_VERSION, css: true }); interface EsriModules { EsriMap: typeof EsriMapConstructor; MapView: typeof MapViewConstructor; Graphic: typeof GraphicConstructor; GraphicsLayer: typeof GraphicsLayerConstructor; Circle: typeof CircleConstructor; esriConfig: typeof EsriConfigModule; } let modulesPromise: Promise | null = null; // Cached at module scope (not per-mount) so navigating between pages that // each render a map doesn't re-trigger the CDN load — esri-loader caches // the underlying script tag globally too, but this also skips re-running // the loadModules() round trip on every mount. function loadEsriModules(): Promise { if (!modulesPromise) { modulesPromise = loadModules([ 'esri/Map', 'esri/views/MapView', 'esri/Graphic', 'esri/layers/GraphicsLayer', 'esri/geometry/Circle', 'esri/config', ]).then(([EsriMap, MapView, Graphic, GraphicsLayer, Circle, esriConfig]: any[]) => { esriConfig.apiKey = API_KEY; return { EsriMap, MapView, Graphic, GraphicsLayer, Circle, esriConfig } as EsriModules; }); } return modulesPromise; } function colorFor(utilityType: string): string { return UTILITY_COLORS[utilityType] ?? UTILITY_COLORS.UNKNOWN; } function orderKey(p: MapPoint): number { return p.sequence ?? new Date(p.recordedAt).getTime(); } // Identifies whether the set of points (including each one's own position) // has actually changed, so a moving point re-fits bounds even when the // count doesn't change (e.g. a device's single "current location" point). function pointsSignature(points: MapPoint[]): string { return points.map((p) => `${p.id}:${p.lat.toFixed(7)}:${p.lng.toFixed(7)}`).join('|'); } function popupHtml(point: MapPoint): string { const rows = pointDetailRows(point) .map( ([label, value]) => `${label}${value}`, ) .join(''); return `${rows}
`; } // Esri opens/closes/dismisses-on-outside-click popups natively via each // graphic's popupTemplate — no hand-rolled "selected point" state needed. function pointGraphic(mods: EsriModules, point: MapPoint, color: string) { return new mods.Graphic({ geometry: { type: 'point', longitude: point.lng, latitude: point.lat }, symbol: { type: 'simple-marker', style: 'circle', color, size: 10, outline: { color: '#ffffff', width: 1.5 }, }, popupTemplate: { title: `${point.utilityType} · ${point.fixType}`, content: popupHtml(point), }, } as ConstructorParameters[0]); } function lineGraphic(mods: EsriModules, points: MapPoint[], color: string) { return new mods.Graphic({ geometry: { type: 'polyline', paths: [points.map((p) => [p.lng, p.lat])], }, symbol: { type: 'simple-line', color, width: 3, opacity: 0.8, }, } as ConstructorParameters[0]); } function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) { const position = { type: 'point' as const, longitude: status.lng, latitude: status.lat }; const marker = new mods.Graphic({ geometry: position, symbol: { type: 'simple-marker', style: 'circle', color: LIVE_COLOR, size: 16, outline: { color: '#ffffff', width: 2 }, }, popupTemplate: { title: `Live · ${status.serial}`, content: popupHtml(liveStatusToMapPoint(status)), }, } as ConstructorParameters[0]); const halo = new mods.Graphic({ geometry: new mods.Circle({ center: [status.lng, status.lat], radius: status.hAccuracy ?? 5, radiusUnit: 'meters', geodesic: true, }), symbol: { type: 'simple-fill', color: [...hexToRgb(LIVE_COLOR), 0.15], outline: { color: [...hexToRgb(LIVE_COLOR), 0.3], width: 1 }, }, } as ConstructorParameters[0]); return [halo, marker]; } function hexToRgb(hex: string): [number, number, number] { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } export default function EsriJobMap({ points, liveStatus = null, fitBounds = true, heightPx = 480 }: JobMapProps) { const containerRef = useRef(null); const viewRef = useRef | null>(null); const pointsLayerRef = useRef | null>(null); const liveLayerRef = useRef | null>(null); const modsRef = useRef(null); const fittedSignatureRef = useRef(''); const [ready, setReady] = useState(false); useEffect(() => { if (!containerRef.current || !API_KEY) { return; } let cancelled = false; let view: InstanceType | undefined; loadEsriModules().then((mods) => { if (cancelled || !containerRef.current) { return; } const pointsLayer = new mods.GraphicsLayer(); const liveLayer = new mods.GraphicsLayer(); const map = new mods.EsriMap({ basemap: 'hybrid', layers: [pointsLayer, liveLayer] }); view = new mods.MapView({ container: containerRef.current, map, center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, }); modsRef.current = mods; pointsLayerRef.current = pointsLayer; liveLayerRef.current = liveLayer; viewRef.current = view; setReady(true); }); return () => { cancelled = true; view?.destroy(); viewRef.current = null; pointsLayerRef.current = null; liveLayerRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { const view = viewRef.current; const layer = pointsLayerRef.current; const mods = modsRef.current; if (!view || !layer || !mods) { return; } layer.removeAll(); const byUtility = new Map(); for (const p of points) { const group = byUtility.get(p.utilityType) ?? []; group.push(p); byUtility.set(p.utilityType, group); } for (const [utility, group] of byUtility) { const sorted = [...group].sort((a, b) => orderKey(a) - orderKey(b)); const color = colorFor(utility); layer.add(lineGraphic(mods, sorted, color)); for (const p of sorted) { layer.add(pointGraphic(mods, p, color)); } } const signature = pointsSignature(points); if (fitBounds && signature !== fittedSignatureRef.current && points.length > 0) { fittedSignatureRef.current = signature; view.when(() => { const target = points.length === 1 ? { center: [points[0].lng, points[0].lat] } : layer.graphics.toArray(); view.goTo(target).catch((err: Error) => { if (err.name !== 'view:goTo-aborted') { console.error('EsriJobMap: goTo failed', err); } }); }); } }, [points, fitBounds, ready]); useEffect(() => { const view = viewRef.current; const layer = liveLayerRef.current; const mods = modsRef.current; if (!view || !layer || !mods) { return; } layer.removeAll(); if (!liveStatus) { return; } liveMarkerGraphics(mods, liveStatus).forEach((g) => layer.add(g)); view.when(() => { view.goTo({ center: [liveStatus.lng, liveStatus.lat] }).catch((err: Error) => { if (err.name !== 'view:goTo-aborted') { console.error('EsriJobMap: goTo failed', err); } }); }); }, [liveStatus, ready]); if (!API_KEY) { return (
Set NEXT_PUBLIC_ARCGIS_API_KEY in .env to enable the map.
); } return (
); }