diff --git a/src/app-bundles/download-bundle.js b/src/app-bundles/download-bundle.js
index 8fb0f6c..df95c31 100644
--- a/src/app-bundles/download-bundle.js
+++ b/src/app-bundles/download-bundle.js
@@ -65,8 +65,10 @@ const downloadBundle = createRestBundle({
}
return downloads.filter(
(d) =>
- d.status === 'INITIATED' &&
- d.progress < 100 &&
+ // Continue polling if status is INITIATED OR progress < 100 (not both)
+ // This ensures we keep polling until we receive a response where BOTH
+ // status != 'INITIATED' AND progress == 100
+ (d.status === 'INITIATED' || d.progress < 100) &&
// it is possible a stuck download ('INITIATED' and < 100 progress) could remain in the database.
// When that happens this function will call the actionCreator doDownloadPoll
// in an infinite loop forever. Only evaluate downloads from the past 8 hours.
diff --git a/src/app-bundles/user-regions-bundle.js b/src/app-bundles/user-regions-bundle.js
index f90557b..3f6e99d 100644
--- a/src/app-bundles/user-regions-bundle.js
+++ b/src/app-bundles/user-regions-bundle.js
@@ -24,9 +24,9 @@ export default createRestBundle({
// Override the save method to return response status
doUserRegionSave: (item) => ({ dispatch, store }) => {
const authToken = store.selectAuthToken();
-
+
dispatch({ type: 'USER_REGION_SAVE_START' });
-
+
return fetch(`${apiURL}/user-regions`, {
method: 'POST',
headers: {
@@ -60,6 +60,46 @@ export default createRestBundle({
return { success: false, error };
});
},
+
+ // Update existing region using PUT
+ doUserRegionUpdate: (item) => ({ dispatch, store }) => {
+ const authToken = store.selectAuthToken();
+
+ dispatch({ type: 'USER_REGION_UPDATE_START' });
+
+ return fetch(`${apiURL}/user-regions/${item.id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + authToken,
+ },
+ body: JSON.stringify(item),
+ })
+ .then(async response => {
+ if (response.ok) {
+ const data = await response.json();
+ dispatch({
+ type: 'USER_REGION_SAVE_FINISHED',
+ payload: data
+ });
+ return { success: true, data };
+ } else {
+ const errorText = await response.text();
+ dispatch({
+ type: 'USER_REGION_UPDATE_ERROR',
+ payload: { status: response.status, message: errorText }
+ });
+ return { success: false, status: response.status, message: errorText };
+ }
+ })
+ .catch(error => {
+ dispatch({
+ type: 'USER_REGION_UPDATE_ERROR',
+ payload: error
+ });
+ return { success: false, error };
+ });
+ },
// Select public regions
selectUserRegionPublicItems: (state) => {
diff --git a/src/app-components/cumulus-map/cumulus-map-base.js b/src/app-components/cumulus-map/cumulus-map-base.js
new file mode 100644
index 0000000..9260c96
--- /dev/null
+++ b/src/app-components/cumulus-map/cumulus-map-base.js
@@ -0,0 +1,76 @@
+import { useEffect } from 'react';
+import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet';
+import { useMap } from 'react-leaflet/hooks';
+
+// Component to handle map invalidation on resize
+function MapInvalidator({ mapHeight }) {
+ const map = useMap();
+
+ useEffect(() => {
+ if (map && mapHeight) {
+ // Small delay to let DOM update
+ const timer = setTimeout(() => {
+ map.invalidateSize();
+ }, 100);
+
+ return () => clearTimeout(timer);
+ }
+ }, [map, mapHeight]);
+
+ return null;
+}
+
+/**
+ * Base Cumulus Map Container
+ *
+ * A reusable Leaflet map component that provides the core map functionality
+ * for all map modes in the application.
+ *
+ * @param {Object} props
+ * @param {React.ReactNode} props.children - Mode-specific overlays and controls
+ * @param {number} [props.mapHeight] - Height of the map for resizing
+ * @param {Array} [props.center=[37.1, -95.7]] - Map center coordinates [lat, lng]
+ * @param {number} [props.zoom=4] - Initial zoom level
+ * @param {Array} [props.locations=[]] - Optional markers to display
+ */
+export default function CumulusMapBase({
+ children,
+ mapHeight,
+ center = [37.1, -95.7],
+ zoom = 4,
+ locations = []
+}) {
+ return (
+
+
+
+
+ {/* Handle map invalidation when height changes */}
+
+
+ {/* Mode-specific overlays and controls */}
+ {children}
+
+ {/* Optional location markers */}
+ {locations.map((loc, i) => (
+
+
+ {loc.name}
+ {loc.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app-components/cumulus-map/cumulus-map.js b/src/app-components/cumulus-map/cumulus-map.js
new file mode 100644
index 0000000..cf403e6
--- /dev/null
+++ b/src/app-components/cumulus-map/cumulus-map.js
@@ -0,0 +1,114 @@
+import { useRef, forwardRef } from 'react';
+import CumulusMapBase from './cumulus-map-base';
+import RegionDefineOverlay from './modes/region-define/region-define-overlay';
+import UsaLabelOverlay from './modes/usa-label/usa-label-overlay';
+import RegionDefineControls from './modes/region-define/region-define-controls';
+import UsaLabelControls from './modes/usa-label/usa-label-controls';
+
+/**
+ * Stable wrapper component for rendering mode-specific overlays
+ * This component type never changes, only the internal component does
+ */
+const OverlayRenderer = forwardRef(({ mode, modeConfig, ...props }, ref) => {
+ const config = modeConfig[mode] || modeConfig['region-define'];
+ const Component = config.overlay;
+ return ;
+});
+OverlayRenderer.displayName = 'OverlayRenderer';
+
+/**
+ * Stable wrapper component for rendering mode-specific controls
+ * This component type never changes, only the internal component does
+ */
+const ControlsRenderer = ({ mode, modeConfig, children, ...props }) => {
+ const config = modeConfig[mode] || modeConfig['region-define'];
+ const Component = config.controls;
+ return {children} ;
+};
+
+/**
+ * Cumulus Map - Main Map Controller
+ *
+ * A flexible map component that supports multiple operational modes.
+ * Modes can be switched by passing the `mode` prop.
+ *
+ * The base map persists across mode changes - only overlays and controls swap out.
+ * This prevents the map from reloading tiles when switching modes.
+ *
+ * Available modes:
+ * - 'region-define': Interactive region drawing and management
+ * - 'usa-label': Simple USA label display
+ *
+ * To add a new mode:
+ * 1. Create overlay component in modes//-overlay.js
+ * 2. Create controls component in modes//-controls.js
+ * 3. Import them and add to modeConfig below
+ *
+ * @param {Object} props
+ * @param {string} [props.mode='region-define'] - The map mode to display
+ * @param {number} [props.mapHeight] - Height of the map
+ *
+ * @example
+ * // Region define mode
+ *
+ *
+ * @example
+ * // USA label mode
+ *
+ */
+export default function CumulusMap({
+ mode = 'region-define',
+ mapHeight,
+ ...props
+}) {
+ const mapOverlayRef = useRef(null);
+
+ // Map mode configurations - add new modes here
+ const modeConfig = {
+ 'region-define': {
+ overlay: RegionDefineOverlay,
+ controls: RegionDefineControls,
+ },
+ 'usa-label': {
+ overlay: UsaLabelOverlay,
+ controls: UsaLabelControls,
+ },
+ };
+
+ return (
+
+
+ {/* Persistent base map */}
+
+ {/* Mode-specific overlay via stable wrapper */}
+
+
+
+ {/* Mode-specific map overlay controls via stable wrapper */}
+
+ {({ mapOverlay }) => mapOverlay}
+
+
+
+ {/* Render footer controls below the map via stable wrapper */}
+
+ {({ footer }) => footer}
+
+
+ );
+}
diff --git a/src/app-components/cumulus-map/index.js b/src/app-components/cumulus-map/index.js
new file mode 100644
index 0000000..0b8bb56
--- /dev/null
+++ b/src/app-components/cumulus-map/index.js
@@ -0,0 +1,16 @@
+/**
+ * Cumulus Map - Main Export
+ *
+ * This index file provides a clean way to import the map component:
+ * import CumulusMap from 'app-components/cumulus-map';
+ */
+export { default } from './cumulus-map';
+export { default as CumulusMapBase } from './cumulus-map-base';
+
+// Region Define Mode exports
+export { default as RegionDefineOverlay } from './modes/region-define/region-define-overlay';
+export { default as RegionDefineControls } from './modes/region-define/region-define-controls';
+
+// USA Label Mode exports
+export { default as UsaLabelOverlay } from './modes/usa-label/usa-label-overlay';
+export { default as UsaLabelControls } from './modes/usa-label/usa-label-controls';
diff --git a/src/app-components/cumulus-map/modes/region-define/region-define-controls.js b/src/app-components/cumulus-map/modes/region-define/region-define-controls.js
new file mode 100644
index 0000000..761b3dc
--- /dev/null
+++ b/src/app-components/cumulus-map/modes/region-define/region-define-controls.js
@@ -0,0 +1,403 @@
+import { useState, useEffect, useCallback } from 'react';
+import { connect } from 'redux-bundler-react';
+
+export default connect(
+ 'doUserRegionSave',
+ 'doUserRegionUpdate',
+ 'doUserRegionFetch',
+ 'doUserRegionDelete',
+ 'doUserRegionFetchPublic',
+ 'selectUserRegionAllAvailable',
+ function RegionDefineControls({
+ children,
+ mapOverlayRef,
+ doUserRegionSave,
+ doUserRegionUpdate,
+ doUserRegionFetch,
+ doUserRegionDelete,
+ doUserRegionFetchPublic,
+ userRegionAllAvailable
+ }) {
+ // Unified region state
+ const [currentRegion, setCurrentRegion] = useState({
+ source: null, // 'drawn' | 'api' | 'uploaded'
+ data: null, // GeoJSON FeatureCollection
+ name: '', // Region name
+ id: null, // ID if from API
+ isEditable: false // Can it be modified?
+ });
+
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveStatus, setSaveStatus] = useState(null); // 'success' | 'error' | null
+
+ // Clear save status after a delay
+ useEffect(() => {
+ if (saveStatus) {
+ const timer = setTimeout(() => setSaveStatus(null), 3000);
+ return () => clearTimeout(timer);
+ }
+ }, [saveStatus]);
+
+ // Fetch regions on mount
+ useEffect(() => {
+ doUserRegionFetch();
+ doUserRegionFetchPublic();
+ }, [doUserRegionFetch, doUserRegionFetchPublic]);
+
+ // Helper to create proper FeatureCollection
+ const createFeatureCollection = (geometry, properties = {}) => {
+ return {
+ type: 'FeatureCollection',
+ features: [{
+ type: 'Feature',
+ geometry: geometry,
+ properties: properties
+ }]
+ };
+ };
+
+ // Centralized action handler for all map interactions
+ const handleMapAction = useCallback((action) => {
+
+ switch (action.type) {
+ case 'START_DRAW':
+ // Always clear and start fresh when starting to draw
+ if (mapOverlayRef.current) {
+ mapOverlayRef.current.clearAll();
+ mapOverlayRef.current.startDrawing();
+ }
+ setCurrentRegion({
+ source: 'drawn',
+ data: null,
+ name: '',
+ id: null,
+ isEditable: true
+ });
+ break;
+
+ case 'DRAWN':
+ setCurrentRegion({
+ source: 'drawn',
+ data: action.data,
+ name: '',
+ id: null,
+ isEditable: true
+ });
+ break;
+
+ case 'START_EDIT':
+ setCurrentRegion(prev => ({
+ ...prev,
+ source: 'drawn',
+ isEditable: true
+ }));
+ break;
+
+ case 'UPDATE':
+ setCurrentRegion(prev => ({
+ ...prev,
+ data: action.data,
+ source: 'drawn',
+ isEditable: true
+ }));
+ break;
+
+ case 'CANCEL_EDIT':
+ if (currentRegion.id) {
+ setCurrentRegion(prev => ({
+ ...prev,
+ source: 'api',
+ isEditable: false
+ }));
+ }
+ break;
+
+ case 'SELECT_API':
+ const region = userRegionAllAvailable.find(r => r.id === action.id);
+ if (region) {
+ const featureCollection = createFeatureCollection(region.geojson, {
+ name: region.name,
+ id: region.id
+ });
+ setCurrentRegion({
+ source: 'api',
+ data: featureCollection,
+ name: region.name,
+ id: region.id,
+ isEditable: false
+ });
+ if (mapOverlayRef.current) {
+ mapOverlayRef.current.displayRegion(featureCollection);
+ }
+ }
+ break;
+
+ case 'UPLOADED':
+ const uploadedFeatureCollection = action.data.type === 'FeatureCollection'
+ ? action.data
+ : createFeatureCollection(action.data.geometry || action.data);
+
+ setCurrentRegion({
+ source: 'uploaded',
+ data: uploadedFeatureCollection,
+ name: action.name || 'Uploaded Region',
+ id: null,
+ isEditable: true
+ });
+ break;
+
+ case 'DELETE':
+ setCurrentRegion({
+ source: null,
+ data: null,
+ name: '',
+ id: null,
+ isEditable: false
+ });
+ if (mapOverlayRef.current) {
+ mapOverlayRef.current.clearAll();
+ }
+ break;
+
+ case 'UPDATE_NAME':
+ setCurrentRegion(prev => ({ ...prev, name: action.name }));
+ break;
+
+ case 'CLEAR_SELECTION':
+ setCurrentRegion({
+ source: null,
+ data: null,
+ name: '',
+ id: null,
+ isEditable: false
+ });
+ if (mapOverlayRef.current) {
+ mapOverlayRef.current.clearAll();
+ }
+ break;
+ default:
+ break;
+ }
+ }, [userRegionAllAvailable, currentRegion.id, mapOverlayRef]);
+
+ const handleSaveRegion = async () => {
+ if (!currentRegion.data || !currentRegion.name.trim()) {
+ setSaveStatus('error');
+ return;
+ }
+
+ const geometry = currentRegion.data.features[0]?.geometry;
+ if (!geometry) return;
+
+ setIsSaving(true);
+ setSaveStatus(null);
+
+ try {
+ const isUpdate = currentRegion.id !== null && currentRegion.id !== undefined;
+ const payload = {
+ name: currentRegion.name.trim(),
+ geojson: geometry,
+ is_public: false
+ };
+
+ if (isUpdate) {
+ payload.id = currentRegion.id;
+ }
+
+ const result = isUpdate
+ ? await doUserRegionUpdate(payload)
+ : await doUserRegionSave(payload);
+
+ if (result.success) {
+ const savedData = result.data;
+
+ setCurrentRegion({
+ source: 'api',
+ data: currentRegion.data,
+ name: savedData.name,
+ id: savedData.id,
+ isEditable: false
+ });
+ setSaveStatus('success');
+
+ if (mapOverlayRef.current && currentRegion.data) {
+ mapOverlayRef.current.displayRegion(currentRegion.data);
+ }
+
+ await doUserRegionFetch();
+ await doUserRegionFetchPublic();
+ } else {
+ setSaveStatus('error');
+ }
+ } catch (error) {
+ setSaveStatus('error');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ // Setup the onAction callback for the overlay
+ useEffect(() => {
+ if (mapOverlayRef.current && mapOverlayRef.current.setOnAction) {
+ mapOverlayRef.current.setOnAction(handleMapAction);
+ }
+ }, [mapOverlayRef, handleMapAction]);
+
+ // Render using render prop pattern
+ return children({
+ mapOverlay: (
+
+
+
+
+
+
Drawn Region (Web Mercator - ESPG 3857)
+
+
+
+
Reprojected Region (CONUS Albers - ESPG5070)
+
+
+
+
+ ),
+ footer: (
+
+
+
+
+ {userRegionAllAvailable && userRegionAllAvailable.length > 0 && (
+
{
+ const regionId = e.target.value;
+ if (!regionId) {
+ handleMapAction({ type: 'CLEAR_SELECTION' });
+ } else {
+ handleMapAction({ type: 'SELECT_API', id: regionId });
+ }
+ }}
+ className='text-sm px-2 py-1 w-64 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500'
+ >
+ Select existing region...
+ {userRegionAllAvailable
+ .filter(region => region && region.id && region.name)
+ .map(region => (
+
+ {region.name} {region.isPublic ? '(Public)' : ''}
+
+ ))}
+
+ )}
+
+ {currentRegion.source === 'api' && currentRegion.id && !userRegionAllAvailable.find(r => r.id === currentRegion.id)?.isPublic && (
+
{
+ const region = userRegionAllAvailable.find(r => r.id === currentRegion.id);
+ if (region && window.confirm(`Delete region "${region.name}"?`)) {
+ await doUserRegionDelete(region);
+ handleMapAction({ type: 'DELETE' });
+ doUserRegionFetch();
+ }
+ }}
+ className='p-1 text-red-600 hover:bg-red-50 rounded'
+ title='Delete selected region'
+ >
+
+
+
+
+ )}
+
+ {currentRegion.data && (currentRegion.source === 'drawn' || currentRegion.source === 'uploaded') && (
+ <>
+
handleMapAction({ type: 'UPDATE_NAME', name: e.target.value })}
+ placeholder='Enter region name'
+ className={`text-sm px-2 py-1 border rounded-md focus:ring-indigo-500 focus:border-indigo-500 ${
+ saveStatus === 'error' && !currentRegion.name.trim()
+ ? 'border-red-500'
+ : 'border-gray-300'
+ }`}
+ />
+
+ {isSaving ? (
+ <>
+
+
+
+
+ Saving...
+ >
+ ) : saveStatus === 'success' ? (
+ <>
+
+
+
+ Saved!
+ >
+ ) : saveStatus === 'error' ? (
+ <>
+
+
+
+ Error
+ >
+ ) : (
+ 'Save'
+ )}
+
+ >
+ )}
+
+
+
+ {saveStatus === 'success' ? (
+
+
+
+
+
Region saved successfully!
+
+ ) : saveStatus === 'error' ? (
+
+
+
+
+
+ {!currentRegion.name.trim() ? 'Please enter a region name' : 'Failed to save region'}
+
+
+ ) : (
+
+ {currentRegion.source === 'api'
+ ? `Selected: ${currentRegion.name}`
+ : currentRegion.source === 'drawn'
+ ? 'Draw region on map'
+ : currentRegion.source === 'uploaded'
+ ? 'Uploaded region'
+ : 'Draw or select a region'
+ }
+
+ )}
+
+
+
+
+ )
+ });
+});
diff --git a/src/app-components/cumulus-map/modes/region-define/region-define-overlay.js b/src/app-components/cumulus-map/modes/region-define/region-define-overlay.js
new file mode 100644
index 0000000..740cca9
--- /dev/null
+++ b/src/app-components/cumulus-map/modes/region-define/region-define-overlay.js
@@ -0,0 +1,580 @@
+import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react';
+import { useMap } from 'react-leaflet/hooks';
+import * as Leaflet from 'leaflet';
+import {
+ TerraDraw,
+ TerraDrawRectangleMode,
+ TerraDrawRenderMode,
+ TerraDrawSelectMode,
+} from 'terra-draw';
+import { TerraDrawLeafletAdapter } from 'terra-draw-leaflet-adapter';
+import shp from 'shpjs';
+import * as turf from '@turf/turf';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { TrashIcon, ArrowUpTrayIcon, PencilSquareIcon } from '@heroicons/react/24/outline';
+
+// Import EPSG:5070 conversion function from shared utilities
+import { convertTo5070Rectangle } from '../../shared/epsg5070-utils';
+
+// Configuration
+const ENABLE_EPSG5070_CONVERSION = true;
+
+// Convert React icons to HTML strings for Leaflet.easyButton
+const trashIcon = renderToStaticMarkup( );
+const uploadIcon = renderToStaticMarkup( );
+const editIcon = renderToStaticMarkup( );
+
+// Style constants for consistency
+const STYLES = {
+ original: {
+ color: '#3b82f6',
+ weight: 2,
+ fillColor: '#3b82f6',
+ fillOpacity: 0.1,
+ },
+ epsg5070: {
+ color: '#10b981',
+ weight: 3,
+ fillColor: '#10b981',
+ fillOpacity: 0.1,
+ dashArray: '10, 5',
+ },
+ simplified: {
+ color: '#3b82f6',
+ weight: 2,
+ fillOpacity: 0.0,
+ dashArray: '5, 5',
+ },
+ bbox: {
+ color: '#3b82f6',
+ weight: 3,
+ fillOpacity: 0.0,
+ }
+};
+
+// Helper to normalize feature IDs
+const normalizeId = (id) => typeof id === 'string' ? id : id?.toString();
+
+const RegionDefineOverlay = forwardRef(function ({ onAction }, ref) {
+ const draw = useRef(null);
+ const featureId = useRef(null);
+ const layerGroup = useRef(null);
+ const transformedLayer = useRef(null); // Layer for EPSG:5070 transformed shapes
+ const fileInput = useRef(null);
+ const map = useMap();
+
+ // Track whether we're editing an existing region vs creating a new one
+ const isEditingExisting = useRef(false);
+
+ // Persistent storage for TerraDraw state across hot reloads
+ const persistentState = useRef({
+ features: [],
+ mode: 'render'
+ });
+
+ // Helper function to display EPSG:5070 transformed shape
+ const displayEPSG5070Shape = useCallback((geometry) => {
+ if (!ENABLE_EPSG5070_CONVERSION || !geometry) return null;
+
+ // Remove any existing transformed layer
+ if (transformedLayer.current) {
+ map.removeLayer(transformedLayer.current);
+ transformedLayer.current = null;
+ }
+
+ // Convert to EPSG:5070 aligned rectangle
+ const aligned5070Geometry = convertTo5070Rectangle(geometry);
+
+ // Add the transformed shape as a Leaflet layer
+ transformedLayer.current = Leaflet.geoJSON({
+ type: 'Feature',
+ geometry: aligned5070Geometry,
+ properties: {
+ epsg5070_bounds: aligned5070Geometry.epsg5070_bounds,
+ is_epsg5070_transformed: true
+ }
+ }, {
+ style: STYLES.epsg5070,
+ interactive: false // Make it non-interactive so clicks pass through
+ }).addTo(map);
+
+ return aligned5070Geometry;
+ }, [map]);
+
+ const clearAll = useCallback(() => {
+ if (!draw.current) return;
+
+ draw.current.clear();
+ if (layerGroup.current) {
+ map.removeLayer(layerGroup.current);
+ layerGroup.current = null;
+ }
+ if (transformedLayer.current) {
+ map.removeLayer(transformedLayer.current);
+ transformedLayer.current = null;
+ }
+ featureId.current = null;
+ isEditingExisting.current = false; // Reset flag when clearing
+
+ // Clear persistent state
+ persistentState.current = {
+ features: [],
+ mode: 'render'
+ };
+ }, [map]);
+
+ const startDrawing = useCallback(() => {
+ if (!draw.current) return;
+ clearAll();
+ isEditingExisting.current = false; // Mark as creating new region
+ draw.current.setMode('rectangle');
+ }, [clearAll]);
+
+ const displayRegion = useCallback((regionData) => {
+ if (!draw.current) return;
+
+ draw.current.setMode('render');
+ clearAll();
+
+ if (!regionData?.features?.[0]) return;
+
+ const feature = regionData.features[0];
+
+ // Transform feature to match TerraDraw's expected format
+ const terraDrawFeature = {
+ type: 'Feature',
+ id: feature.properties?.id || feature.id, // Use id from properties or top-level
+ geometry: feature.geometry,
+ properties: {
+ ...feature.properties,
+ mode: 'rectangle' // TerraDraw requires mode property
+ }
+ };
+
+ // Mark as editing an existing region
+ isEditingExisting.current = true;
+
+ // Add to TerraDraw
+ const ids = draw.current.addFeatures([terraDrawFeature]);
+
+ if (ids?.[0]) {
+ featureId.current = normalizeId(ids[0]);
+ }
+
+ // Display the EPSG:5070 transformed shape
+ displayEPSG5070Shape(feature.geometry);
+
+ // Update persistent state
+ persistentState.current = {
+ features: draw.current.getSnapshot(),
+ mode: 'render'
+ };
+ }, [clearAll, displayEPSG5070Shape]);
+
+ // Ref to store onAction callback
+ const onActionCallback = useRef(onAction);
+
+ // Allow external setting of onAction
+ const setOnAction = useCallback((callback) => {
+ onActionCallback.current = callback;
+ }, []);
+
+ // Use the callback ref instead of direct onAction prop
+ useEffect(() => {
+ onActionCallback.current = onAction;
+ }, [onAction]);
+
+ useImperativeHandle(
+ ref,
+ () => {
+ return {
+ disableEditing,
+ displayRegion,
+ clearAll,
+ startDrawing,
+ setOnAction
+ };
+ },
+ [clearAll, displayRegion, startDrawing, setOnAction]
+ );
+
+ const disableEditing = () => {
+ if (draw.current) {
+ draw.current.setMode('render');
+ }
+ };
+
+ const onChange = useCallback(() => {
+ const currentMode = draw.current.getMode();
+
+ // Handle changes in both rectangle (new) and select (edit) modes
+ if (currentMode !== 'rectangle' && currentMode !== 'select') return;
+
+ const snapshot = draw.current.getSnapshot()[0];
+ if (!snapshot) return;
+
+ featureId.current = normalizeId(snapshot.id);
+
+ // Update persistent state
+ persistentState.current = {
+ features: draw.current.getSnapshot(),
+ mode: currentMode
+ };
+
+ // Show EPSG:5070 preview while drawing/editing
+ displayEPSG5070Shape(snapshot.geometry);
+
+ // Emit appropriate action based on whether we're editing or creating
+ const actionType = isEditingExisting.current ? 'UPDATE' : 'DRAWN';
+
+ onActionCallback.current?.({
+ type: actionType,
+ data: { type: 'FeatureCollection', features: [snapshot] }
+ });
+ }, [displayEPSG5070Shape]);
+
+ const onCreate = useCallback((id) => {
+ const allFeatures = draw.current.getSnapshot();
+
+ // Keep only the newest feature if multiple exist
+ if (allFeatures.length > 1) {
+ draw.current.clear();
+ const newestFeature = allFeatures.find(f => f.id === id);
+ if (newestFeature) {
+ const newIds = draw.current.addFeatures([newestFeature]);
+ id = newIds?.[0] ? normalizeId(newIds[0]) : id;
+ }
+ }
+
+ const snapshot = draw.current.getSnapshot()[0];
+ if (!snapshot?.geometry) return;
+
+ // Display and store EPSG:5070 transformation
+ const aligned5070Geometry = displayEPSG5070Shape(snapshot.geometry);
+ if (aligned5070Geometry) {
+ snapshot.properties = {
+ ...snapshot.properties,
+ original_geometry: snapshot.geometry,
+ epsg5070_geometry: aligned5070Geometry,
+ epsg5070_bounds: aligned5070Geometry.epsg5070_bounds
+ };
+ }
+
+ draw.current.setMode('render');
+ featureId.current = normalizeId(id);
+
+ // Update persistent state
+ persistentState.current = {
+ features: draw.current.getSnapshot(),
+ mode: 'render'
+ };
+
+ // Emit appropriate action based on whether we're editing or creating
+ const actionType = isEditingExisting.current ? 'UPDATE' : 'DRAWN';
+
+ onActionCallback.current?.({
+ type: actionType,
+ data: { type: 'FeatureCollection', features: [snapshot] }
+ });
+ }, [displayEPSG5070Shape]);
+
+ const onEdit = useCallback(() => {
+ if (onActionCallback.current) {
+ isEditingExisting.current = false; // Reset flag when starting a new draw
+ onActionCallback.current({ type: 'START_DRAW' });
+ }
+ }, []);
+
+ const onDelete = useCallback(() => {
+ if (onActionCallback.current) {
+ onActionCallback.current({ type: 'DELETE' });
+ }
+ }, []);
+
+ // Store callback refs to avoid re-initializing TerraDraw when they change
+ const onChangeRef = useRef(onChange);
+ const onCreateRef = useRef(onCreate);
+ const onEditRef = useRef(onEdit);
+ const onDeleteRef = useRef(onDelete);
+
+ // Update refs when callbacks change
+ useEffect(() => {
+ onChangeRef.current = onChange;
+ onCreateRef.current = onCreate;
+ onEditRef.current = onEdit;
+ onDeleteRef.current = onDelete;
+ }, [onChange, onCreate, onEdit, onDelete]);
+
+ // Initialize TerraDraw only once on mount
+ useEffect(() => {
+ // Clean up any existing layers on the map before initialization
+ // This ensures a clean slate on page refresh
+ if (transformedLayer.current) {
+ map.removeLayer(transformedLayer.current);
+ transformedLayer.current = null;
+ }
+ if (layerGroup.current) {
+ map.removeLayer(layerGroup.current);
+ layerGroup.current = null;
+ }
+
+ // Use persistent state (which survives hot reloads via ref)
+ const { features: preservedFeatures } = persistentState.current;
+ const preservedFeatureId = featureId.current;
+
+ const terraDraw = new TerraDraw({
+ adapter: new TerraDrawLeafletAdapter({
+ lib: Leaflet,
+ map,
+ }),
+ modes: [
+ new TerraDrawRenderMode({
+ modeName: 'render',
+ cursors: {
+ hover: 'grab',
+ },
+ }),
+ new TerraDrawRectangleMode({
+ cursors: {
+ start: 'crosshair',
+ },
+ }),
+ new TerraDrawSelectMode({
+ flags: {
+ rectangle: {
+ feature: {
+ draggable: true,
+ coordinates: {
+ resizable: 'opposite',
+ },
+ },
+ },
+ },
+ }),
+ ],
+ });
+
+ terraDraw.start();
+
+ // Set draw.current immediately so it's available for all functions
+ draw.current = terraDraw;
+
+ // Restore preserved features if they exist (for hot reload support)
+ if (preservedFeatures && preservedFeatures.length > 0) {
+ terraDraw.addFeatures(preservedFeatures);
+ terraDraw.setMode("render");
+
+ // Re-display the EPSG:5070 shape (creates a new layer)
+ displayEPSG5070Shape(preservedFeatures[0].geometry);
+
+ // Restore the feature ID
+ featureId.current = preservedFeatureId;
+
+ } else {
+ terraDraw.setMode('render'); // Start in render mode, parent will control when to draw
+ }
+
+ // Use refs for callbacks to avoid re-initializing when they change
+ terraDraw.on('change', (...args) => onChangeRef.current(...args));
+ terraDraw.on('finish', (...args) => onCreateRef.current(...args));
+
+ // Add double-click handler to enable dragging
+ const dblClickHandler = (e) => {
+ // Check if we have a feature and are in render mode
+ if (draw.current && draw.current.getMode() === 'render') {
+ const features = draw.current.getSnapshot();
+ if (features && features.length > 0) {
+ // Get the first feature's ID
+ const featureToSelect = features[0].id || features[0];
+ // Switch to select mode and select the feature
+ draw.current.setMode('select');
+ draw.current.selectFeature(featureToSelect);
+
+ // Notify parent that editing has started on existing region
+ if (onActionCallback.current) {
+ onActionCallback.current({ type: 'START_EDIT' });
+ }
+
+ // Prevent map zoom on double-click
+ e.originalEvent.preventDefault();
+ e.originalEvent.stopPropagation();
+ }
+ }
+ };
+ map.on('dblclick', dblClickHandler);
+
+ // Add Escape key handler to exit select mode
+ const keydownHandler = (e) => {
+ if (e.key === 'Escape' && draw.current && draw.current.getMode() === 'select') {
+ draw.current.setMode('render');
+
+ // Notify parent that editing was cancelled
+ if (onActionCallback.current) {
+ onActionCallback.current({ type: 'CANCEL_EDIT' });
+ }
+ }
+ };
+ map.getContainer().addEventListener('keydown', keydownHandler);
+
+ // Create buttons and store references for cleanup, using refs for callbacks
+ const editButton = Leaflet.easyButton({
+ states: [{
+ stateName: 'edit',
+ icon: editIcon,
+ title: 'Draw new region',
+ onClick: () => onEditRef.current()
+ }]
+ }).addTo(map);
+
+ const deleteButton = Leaflet.easyButton({
+ states: [{
+ stateName: 'delete',
+ icon: trashIcon,
+ title: 'Delete region',
+ onClick: () => onDeleteRef.current()
+ }]
+ }).addTo(map);
+
+ const uploadButton = Leaflet.easyButton({
+ states: [{
+ stateName: 'upload',
+ icon: uploadIcon,
+ title: 'Upload shapefile zip',
+ onClick: () => fileInput.current?.click()
+ }]
+ }).addTo(map);
+
+ // Cleanup function
+ return () => {
+ // Remove buttons from map
+ if (editButton) map.removeControl(editButton);
+ if (deleteButton) map.removeControl(deleteButton);
+ if (uploadButton) map.removeControl(uploadButton);
+
+ // Remove event listeners
+ map.off('dblclick', dblClickHandler);
+ map.getContainer().removeEventListener('keydown', keydownHandler);
+
+ // Clean up all layers
+ if (layerGroup.current) {
+ map.removeLayer(layerGroup.current);
+ layerGroup.current = null;
+ }
+ if (transformedLayer.current) {
+ map.removeLayer(transformedLayer.current);
+ transformedLayer.current = null;
+ }
+
+ // Stop and clean up TerraDraw
+ if (terraDraw) {
+ terraDraw.stop();
+ }
+ };
+ }, [map, displayEPSG5070Shape]); // Include displayEPSG5070Shape to fix ESLint warning
+
+
+ const onFileUpload = async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ try {
+ const data = await file.arrayBuffer();
+ const geojson = await shp(data);
+ const simplified = turf.convex(geojson);
+ const bboxRectangle = turf.bboxPolygon(turf.bbox(geojson));
+
+ // Set to render mode before clearing
+ draw.current.setMode('render');
+ clearAll();
+
+ // Create layer group with all visualizations
+ const group = new Leaflet.LayerGroup();
+ group.addLayer(Leaflet.geoJSON(geojson, { style: STYLES.original }));
+ group.addLayer(Leaflet.geoJSON(simplified, { style: STYLES.simplified }));
+ // group.addLayer(Leaflet.geoJSON(bboxRectangle, { style: STYLES.bbox }));
+ group.addTo(map);
+ layerGroup.current = group;
+
+ // Extract bbox bounds and round to 8 decimal places (TerraDraw rejects excessive precision)
+ const roundCoord = (num) => Math.round(num * 100000000) / 100000000;
+ const minLon = roundCoord(bboxRectangle.bbox[0]);
+ const minLat = roundCoord(bboxRectangle.bbox[1]);
+ const maxLon = roundCoord(bboxRectangle.bbox[2]);
+ const maxLat = roundCoord(bboxRectangle.bbox[3]);
+
+ // TerraDraw rectangle expects coordinates in this order:
+ // top-left → bottom-left → bottom-right → top-right → close (top-left)
+ const rectangleCoords = [
+ [
+ [minLon, maxLat], // top-left
+ [minLon, minLat], // bottom-left
+ [maxLon, minLat], // bottom-right
+ [maxLon, maxLat], // top-right
+ [minLon, maxLat] // close (top-left)
+ ]
+ ];
+
+ const geometry = {
+ type: 'Polygon',
+ coordinates: rectangleCoords
+ }
+
+ // Transform bbox rectangle to match TerraDraw's expected format
+ // Let TerraDraw generate the ID
+ const terraDrawFeature = {
+ type: 'Feature',
+ geometry: geometry,
+ properties: {
+ mode: 'rectangle'
+ }
+ };
+
+ // Add to TerraDraw
+ try {
+ const ids = draw.current.addFeatures([terraDrawFeature]);
+
+ if (ids?.[0]) {
+ featureId.current = normalizeId(ids[0]);
+ }
+
+ // Mark as editing existing (uploaded) region
+ isEditingExisting.current = true;
+ } catch (error) {
+ console.error('Error adding feature to TerraDraw:', error);
+ }
+
+ // Update persistent state
+ persistentState.current = {
+ features: draw.current.getSnapshot(),
+ mode: 'render'
+ };
+
+ // Display EPSG:5070 transformation
+ displayEPSG5070Shape(bboxRectangle.geometry);
+
+ // Emit the simplified shape
+ onActionCallback.current?.({
+ type: 'UPLOADED',
+ data: geometry,
+ name: file.name.replace(/\.[^/.]+$/, '')
+ });
+ } catch (error) {
+ console.error('Error uploading file:', error);
+ }
+
+ e.target.value = ''; // Reset for re-selection
+ };
+
+ return (
+
+
+
+ );
+});
+
+export default RegionDefineOverlay;
diff --git a/src/app-components/cumulus-map/modes/usa-label/usa-label-controls.js b/src/app-components/cumulus-map/modes/usa-label/usa-label-controls.js
new file mode 100644
index 0000000..b5f3701
--- /dev/null
+++ b/src/app-components/cumulus-map/modes/usa-label/usa-label-controls.js
@@ -0,0 +1,17 @@
+/**
+ * USA Label Controls
+ *
+ * Simple controls component for the USA label mode.
+ */
+export default function UsaLabelControls({ children }) {
+ return children({
+ mapOverlay: null,
+ footer: (
+
+
+ USA Label Mode - Displaying center of continental United States
+
+
+ )
+ });
+}
diff --git a/src/app-components/cumulus-map/modes/usa-label/usa-label-overlay.js b/src/app-components/cumulus-map/modes/usa-label/usa-label-overlay.js
new file mode 100644
index 0000000..dbf7863
--- /dev/null
+++ b/src/app-components/cumulus-map/modes/usa-label/usa-label-overlay.js
@@ -0,0 +1,39 @@
+import { useEffect } from 'react';
+import { useMap } from 'react-leaflet/hooks';
+import * as Leaflet from 'leaflet';
+
+/**
+ * USA Label Overlay
+ *
+ * Simple overlay that displays "USA" text in the middle of the continental United States.
+ */
+export default function UsaLabelOverlay() {
+ const map = useMap();
+
+ useEffect(() => {
+ // Continental US center approximately
+ const usCenter = [39.8283, -98.5795];
+
+ // Create a custom div icon with "USA" text
+ const usaIcon = Leaflet.divIcon({
+ className: 'usa-label-marker',
+ html: 'USA
',
+ iconSize: [100, 50],
+ iconAnchor: [50, 25]
+ });
+
+ // Add marker to map
+ const marker = Leaflet.marker(usCenter, {
+ icon: usaIcon,
+ interactive: false,
+ keyboard: false
+ }).addTo(map);
+
+ // Cleanup on unmount
+ return () => {
+ map.removeLayer(marker);
+ };
+ }, [map]);
+
+ return null;
+}
diff --git a/src/app-pages/products/products-map/epsg5070-utils.js b/src/app-components/cumulus-map/shared/epsg5070-utils.js
similarity index 100%
rename from src/app-pages/products/products-map/epsg5070-utils.js
rename to src/app-components/cumulus-map/shared/epsg5070-utils.js
diff --git a/src/app-pages/products/download-modal.js b/src/app-pages/products/download-modal.js
index 2e90dda..3f3f093 100644
--- a/src/app-pages/products/download-modal.js
+++ b/src/app-pages/products/download-modal.js
@@ -83,7 +83,6 @@ export default connect(
setSelectedSavedRegion(firstRegion.id);
setCustomRegion(firstRegion.geojson);
setCustomRegionName(firstRegion.name);
- setUseCustomRegion(true);
}
}
}, [userRegionAllAvailable, selectedSavedRegion]);
diff --git a/src/app-pages/products/products-map/products-map-container.js b/src/app-pages/products/products-map/products-map-container.js
deleted file mode 100644
index b9b616a..0000000
--- a/src/app-pages/products/products-map/products-map-container.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useRef, forwardRef, useImperativeHandle, useEffect } from 'react';
-import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet';
-import { useMap } from 'react-leaflet/hooks';
-import ProductsMapOverlay from './products-map-overlay';
-
-// OPTIONAL: Import EPSG:5070 utilities
-// Comment out or remove this line to disable projection conversion
-import { convertTo5070Rectangle, create5070Rectangle } from './epsg5070-utils';
-export { convertTo5070Rectangle, create5070Rectangle };
-
-// Component to handle map invalidation on resize
-function MapInvalidator({ mapHeight }) {
- const map = useMap();
-
- useEffect(() => {
- if (map && mapHeight) {
- // Small delay to let DOM update
- const timer = setTimeout(() => {
- map.invalidateSize();
- }, 100);
-
- return () => clearTimeout(timer);
- }
- }, [map, mapHeight]);
-
- return null;
-}
-
-const ProductsMapContainer = forwardRef(function (props, ref) {
- const mapOverlay = useRef(null);
-
- useImperativeHandle(
- ref,
- () => {
- return {
- disableEditing,
- displayRegion,
- clearAll,
- startDrawing
- };
- },
- []
- );
-
- const disableEditing = () => {
- if (mapOverlay.current) {
- mapOverlay.current.disableEditing();
- }
- };
-
- const displayRegion = (regionData) => {
- if (mapOverlay.current) {
- mapOverlay.current.displayRegion(regionData);
- }
- };
-
- const clearAll = () => {
- if (mapOverlay.current) {
- mapOverlay.current.clearAll();
- }
- };
-
- const startDrawing = () => {
- if (mapOverlay.current) {
- mapOverlay.current.startDrawing();
- }
- };
-
- return (
-
- {/* Using Web Mercator for display, but shapes will be EPSG:5070 squares */}
-
-
-
- {/* Handle map invalidation when height changes */}
-
-
-
- {props.locations.map((loc, i) => (
-
-
- {loc.name}
- {loc.description}
-
-
- ))}
-
-
- );
-});
-
-export default ProductsMapContainer;
diff --git a/src/app-pages/products/products-map/products-map-overlay.js b/src/app-pages/products/products-map/products-map-overlay.js
deleted file mode 100644
index 6e292a8..0000000
--- a/src/app-pages/products/products-map/products-map-overlay.js
+++ /dev/null
@@ -1,368 +0,0 @@
-import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react';
-import { useMap } from 'react-leaflet/hooks';
-import * as Leaflet from 'leaflet';
-import {
- TerraDraw,
- TerraDrawRectangleMode,
- TerraDrawRenderMode,
- TerraDrawSelectMode,
-} from 'terra-draw';
-import { TerraDrawLeafletAdapter } from 'terra-draw-leaflet-adapter';
-import shp from 'shpjs';
-import * as turf from '@turf/turf';
-import { renderToStaticMarkup } from 'react-dom/server';
-import { TrashIcon, ArrowUpTrayIcon, PencilSquareIcon } from '@heroicons/react/24/outline';
-
-// OPTIONAL: Import EPSG:5070 conversion function
-import { convertTo5070Rectangle } from './epsg5070-utils';
-
-// Configuration
-const ENABLE_EPSG5070_CONVERSION = true;
-
-// Convert React icons to HTML strings for Leaflet.easyButton
-const trashIcon = renderToStaticMarkup( );
-const uploadIcon = renderToStaticMarkup( );
-const editIcon = renderToStaticMarkup( );
-
-// Style constants for consistency
-const STYLES = {
- original: {
- color: '#3b82f6',
- weight: 2,
- fillColor: '#3b82f6',
- fillOpacity: 0.1,
- },
- epsg5070: {
- color: '#10b981',
- weight: 3,
- fillColor: '#10b981',
- fillOpacity: 0.1,
- dashArray: '10, 5',
- },
- simplified: {
- color: '#3b82f6',
- weight: 2,
- fillOpacity: 0.0,
- dashArray: '5, 5',
- },
- bbox: {
- color: '#3b82f6',
- weight: 3,
- fillOpacity: 0.0,
- }
-};
-
-// Helper to normalize feature IDs
-const normalizeId = (id) => typeof id === 'string' ? id : id?.toString();
-
-const ProductsMapOverlay = forwardRef(function ({ onAction }, ref) {
- const draw = useRef(null);
- const featureId = useRef(null);
- const layerGroup = useRef(null);
- const transformedLayer = useRef(null); // Layer for EPSG:5070 transformed shapes
- const fileInput = useRef(null);
- const map = useMap();
-
- // Helper function to display EPSG:5070 transformed shape
- const displayEPSG5070Shape = useCallback((geometry) => {
- if (!ENABLE_EPSG5070_CONVERSION || !geometry) return null;
-
- // Remove any existing transformed layer
- if (transformedLayer.current) {
- map.removeLayer(transformedLayer.current);
- transformedLayer.current = null;
- }
-
- // Convert to EPSG:5070 aligned rectangle
- const aligned5070Geometry = convertTo5070Rectangle(geometry);
-
- // Add the transformed shape as a Leaflet layer
- transformedLayer.current = Leaflet.geoJSON({
- type: 'Feature',
- geometry: aligned5070Geometry,
- properties: {
- epsg5070_bounds: aligned5070Geometry.epsg5070_bounds,
- is_epsg5070_transformed: true
- }
- }, {
- style: STYLES.epsg5070,
- interactive: false // Make it non-interactive so clicks pass through
- }).addTo(map);
-
- return aligned5070Geometry;
- }, [map]);
-
- const clearAll = useCallback(() => {
- if (!draw.current) return;
-
- draw.current.clear();
- if (layerGroup.current) {
- map.removeLayer(layerGroup.current);
- layerGroup.current = null;
- }
- if (transformedLayer.current) {
- map.removeLayer(transformedLayer.current);
- transformedLayer.current = null;
- }
- featureId.current = null;
- }, [map]);
-
- const startDrawing = useCallback(() => {
- if (!draw.current) return;
- clearAll();
- draw.current.setMode('rectangle');
- }, [clearAll]);
-
- const displayRegion = useCallback((regionData) => {
- if (!draw.current) return;
-
- draw.current.setMode('render');
- clearAll();
-
- if (!regionData?.features?.[0]) return;
-
- const feature = regionData.features[0];
- const geometry = feature.geometry || feature;
-
- // Add to Leaflet layer for visualization
- layerGroup.current = new Leaflet.LayerGroup();
- layerGroup.current.addLayer(Leaflet.geoJSON(geometry, { style: STYLES.original }));
- layerGroup.current.addTo(map);
-
- // Add to TerraDraw
- const ids = draw.current.addFeatures([feature]);
- if (ids?.[0]) {
- featureId.current = normalizeId(ids[0]);
- }
-
- // Display the EPSG:5070 transformed shape
- displayEPSG5070Shape(geometry);
- }, [clearAll, displayEPSG5070Shape, map]);
-
- useImperativeHandle(
- ref,
- () => {
- return {
- disableEditing,
- displayRegion,
- clearAll,
- startDrawing
- };
- },
- [clearAll, displayRegion, startDrawing]
- );
-
- const disableEditing = () => {
- if (draw.current) {
- draw.current.setMode('render');
- }
- };
-
- const onChange = useCallback(() => {
- if (draw.current.getMode() !== 'rectangle') return;
-
- const snapshot = draw.current.getSnapshot()[0];
- if (!snapshot) return;
-
- featureId.current = normalizeId(snapshot.id);
-
- // Show EPSG:5070 preview while drawing
- displayEPSG5070Shape(snapshot.geometry);
-
- // Emit the feature
- onAction?.({
- type: 'DRAWN',
- data: { type: 'FeatureCollection', features: [snapshot] }
- });
- }, [onAction, displayEPSG5070Shape]);
-
- const onCreate = useCallback((id) => {
- const allFeatures = draw.current.getSnapshot();
-
- // Keep only the newest feature if multiple exist
- if (allFeatures.length > 1) {
- draw.current.clear();
- const newestFeature = allFeatures.find(f => f.id === id);
- if (newestFeature) {
- const newIds = draw.current.addFeatures([newestFeature]);
- id = newIds?.[0] ? normalizeId(newIds[0]) : id;
- }
- }
-
- const snapshot = draw.current.getSnapshot()[0];
- if (!snapshot?.geometry) return;
-
- // Display and store EPSG:5070 transformation
- const aligned5070Geometry = displayEPSG5070Shape(snapshot.geometry);
- if (aligned5070Geometry) {
- snapshot.properties = {
- ...snapshot.properties,
- original_geometry: snapshot.geometry,
- epsg5070_geometry: aligned5070Geometry,
- epsg5070_bounds: aligned5070Geometry.epsg5070_bounds
- };
- }
-
- draw.current.setMode('render');
- featureId.current = normalizeId(id);
-
- // Emit the feature
- onAction?.({
- type: 'DRAWN',
- data: { type: 'FeatureCollection', features: [snapshot] }
- });
- }, [displayEPSG5070Shape, onAction]);
-
- const onEdit = useCallback(() => {
- if (onAction) {
- onAction({ type: 'START_DRAW' });
- }
- }, [onAction]);
-
- const onDelete = useCallback(() => {
- if (onAction) {
- onAction({ type: 'DELETE' });
- }
- }, [onAction]);
-
- // Removed useEffect to prevent infinite loops - using imperative methods instead
-
- useEffect(() => {
- const terraDraw = new TerraDraw({
- adapter: new TerraDrawLeafletAdapter({
- lib: Leaflet,
- map,
- }),
- modes: [
- new TerraDrawRenderMode({
- modeName: 'render',
- }),
- new TerraDrawRectangleMode({
- cursors: {
- start: 'crosshair',
- },
- }),
- new TerraDrawSelectMode({
- flags: {
- rectangle: {
- feature: {
- draggable: true,
- coordinates: {
- resizable: 'opposite',
- },
- },
- },
- },
- }),
- ],
- });
-
- terraDraw.start();
- terraDraw.setMode('render'); // Start in render mode, parent will control when to draw
-
- terraDraw.on('change', onChange);
- terraDraw.on('finish', onCreate);
-
- // Add double-click handler to enable dragging
- const dblClickHandler = (e) => {
- // Check if we have a feature and are in render mode
- if (draw.current && draw.current.getMode() === 'render') {
- const features = draw.current.getSnapshot();
- if (features && features.length > 0) {
- // Get the first feature's ID
- const featureToSelect = features[0].id || features[0];
- // Switch to select mode and select the feature
- draw.current.setMode('select');
- draw.current.selectFeature(featureToSelect);
- // Prevent map zoom on double-click
- e.originalEvent.preventDefault();
- e.originalEvent.stopPropagation();
- }
- }
- };
- map.on('dblclick', dblClickHandler);
-
- // Add Escape key handler to exit select mode
- const keydownHandler = (e) => {
- if (e.key === 'Escape' && draw.current && draw.current.getMode() === 'select') {
- draw.current.setMode('render');
- }
- };
- map.getContainer().addEventListener('keydown', keydownHandler);
-
- // Create buttons and store references for cleanup
- const editButton = Leaflet.easyButton(editIcon, onEdit).addTo(map);
- const deleteButton = Leaflet.easyButton(trashIcon, onDelete).addTo(map);
- const uploadButton = Leaflet.easyButton(uploadIcon, () =>
- fileInput.current?.click(),
- ).addTo(map);
-
- draw.current = terraDraw;
-
- // Cleanup function
- return () => {
- // Remove buttons from map
- if (editButton) map.removeControl(editButton);
- if (deleteButton) map.removeControl(deleteButton);
- if (uploadButton) map.removeControl(uploadButton);
-
- // Remove event listeners
- map.off('dblclick', dblClickHandler);
- map.getContainer().removeEventListener('keydown', keydownHandler);
-
- // Stop and clean up TerraDraw
- if (terraDraw) {
- terraDraw.stop();
- }
- };
- }, [map, onChange, onCreate, onDelete, onEdit]);
-
-
- const onFileUpload = async (e) => {
- const file = e.target.files[0];
- if (!file) return;
-
- try {
- const data = await file.arrayBuffer();
- const geojson = await shp(data);
- const simplified = turf.convex(geojson);
- const bboxRectangle = turf.bboxPolygon(turf.bbox(geojson));
-
- clearAll();
-
- // Create layer group with all visualizations
- const group = new Leaflet.LayerGroup();
- group.addLayer(Leaflet.geoJSON(geojson, { style: STYLES.original }));
- group.addLayer(Leaflet.geoJSON(simplified, { style: STYLES.simplified }));
- group.addLayer(Leaflet.geoJSON(bboxRectangle, { style: STYLES.bbox }));
- group.addTo(map);
- layerGroup.current = group;
-
- // Display EPSG:5070 transformation
- displayEPSG5070Shape(bboxRectangle.geometry);
-
- // Emit the simplified shape
- onAction?.({
- type: 'UPLOADED',
- data: simplified,
- name: file.name.replace(/\.[^/.]+$/, '')
- });
- } catch (error) {
- }
-
- e.target.value = ''; // Reset for re-selection
- };
-
- return (
-
-
-
- );
-});
-
-export default ProductsMapOverlay;
diff --git a/src/app-pages/products/products-map/products-map.js b/src/app-pages/products/products-map/products-map.js
deleted file mode 100644
index 663caf0..0000000
--- a/src/app-pages/products/products-map/products-map.js
+++ /dev/null
@@ -1,416 +0,0 @@
-import { useRef, useState, useEffect, useCallback } from 'react';
-import { connect } from 'redux-bundler-react';
-import ProductsMapContainer from './products-map-container';
-
-export default connect(
- 'doUserRegionSave',
- 'doUserRegionFetch',
- 'doUserRegionDelete',
- 'doUserRegionFetchPublic',
- 'selectUserRegionAllAvailable',
- function ProductsMap({
- isRegionDefineMode = false,
- onRegionSave,
- mapHeight,
- doUserRegionSave,
- doUserRegionFetch,
- doUserRegionDelete,
- doUserRegionFetchPublic,
- userRegionAllAvailable
- }) {
- // Unified region state
- const [currentRegion, setCurrentRegion] = useState({
- source: null, // 'drawn' | 'api' | 'uploaded'
- data: null, // GeoJSON FeatureCollection
- name: '', // Region name
- id: null, // ID if from API
- isEditable: false // Can it be modified?
- });
-
- const [isSaving, setIsSaving] = useState(false);
- const [saveStatus, setSaveStatus] = useState(null); // 'success' | 'error' | null
- const mapContainer = useRef(null);
-
- // Clear save status after a delay
- useEffect(() => {
- if (saveStatus) {
- const timer = setTimeout(() => setSaveStatus(null), 3000);
- return () => clearTimeout(timer);
- }
- }, [saveStatus]);
-
- // Trigger map resize when height changes
- useEffect(() => {
- if (mapHeight && mapContainer.current) {
- // Small delay to ensure DOM has updated
- const timer = setTimeout(() => {
- if (mapContainer.current && mapContainer.current.invalidateSize) {
- mapContainer.current.invalidateSize();
- }
- }, 100);
- return () => clearTimeout(timer);
- }
- }, [mapHeight]);
-
- // Fetch regions on mount and start in draw mode
- useEffect(() => {
- if (isRegionDefineMode) {
- doUserRegionFetch();
- doUserRegionFetchPublic();
- // Start in draw mode automatically after a brief delay to ensure map is ready
- setTimeout(() => {
- if (mapContainer.current) {
- mapContainer.current.startDrawing();
- }
- }, 100);
- }
- }, [isRegionDefineMode, doUserRegionFetch, doUserRegionFetchPublic]);
-
- // Helper to create proper FeatureCollection
- const createFeatureCollection = (geometry, properties = {}) => {
- return {
- type: 'FeatureCollection',
- features: [{
- type: 'Feature',
- geometry: geometry,
- properties: properties
- }]
- };
- };
-
- // Centralized action handler for all map interactions
- const handleMapAction = useCallback((action) => {
-
- switch (action.type) {
- case 'START_DRAW':
- // Always clear and start fresh when starting to draw
- if (mapContainer.current) {
- mapContainer.current.clearAll();
- mapContainer.current.startDrawing();
- }
- setCurrentRegion({
- source: 'drawn',
- data: null,
- name: '',
- id: null,
- isEditable: true
- });
- break;
-
- case 'DRAWN':
- setCurrentRegion({
- source: 'drawn',
- data: action.data,
- name: '',
- id: null,
- isEditable: true
- });
- break;
-
- case 'SELECT_API':
- const region = userRegionAllAvailable.find(r => r.id === action.id);
- if (region) {
- const featureCollection = createFeatureCollection(region.geojson, {
- name: region.name,
- id: region.id
- });
- // Update state and map display immediately
- setCurrentRegion({
- source: 'api',
- data: featureCollection,
- name: region.name,
- id: region.id,
- isEditable: false
- });
- if (mapContainer.current) {
- mapContainer.current.displayRegion(featureCollection);
- }
- }
- break;
-
- case 'UPLOADED':
- const uploadedFeatureCollection = action.data.type === 'FeatureCollection'
- ? action.data
- : createFeatureCollection(action.data.geometry || action.data);
-
- setCurrentRegion({
- source: 'uploaded',
- data: uploadedFeatureCollection,
- name: action.name || 'Uploaded Region',
- id: null,
- isEditable: true
- });
-
- // Display uploaded region on map
- // if (mapContainer.current) {
- // mapContainer.current.displayRegion(uploadedFeatureCollection);
- // }
- break;
-
- case 'DELETE':
- setCurrentRegion({
- source: null,
- data: null,
- name: '',
- id: null,
- isEditable: false
- });
- if (mapContainer.current) {
- mapContainer.current.clearAll();
- }
- break;
-
- case 'UPDATE_NAME':
- setCurrentRegion(prev => ({ ...prev, name: action.name }));
- break;
-
- case 'CLEAR_SELECTION':
- setCurrentRegion({
- source: null,
- data: null,
- name: '',
- id: null,
- isEditable: false
- });
- if (mapContainer.current) {
- mapContainer.current.clearAll();
- }
- break;
- default:
- break;
- }
- }, [userRegionAllAvailable]);
-
- const handleSaveRegion = async () => {
- if (!currentRegion.data || !currentRegion.name.trim()) {
- setSaveStatus('error');
- return;
- }
-
- const geometry = currentRegion.data.features[0]?.geometry;
- if (!geometry) return;
-
- setIsSaving(true);
- setSaveStatus(null);
-
- try {
- // Save to API - now returns {success, data} or {success, status, message}
- const result = await doUserRegionSave({
- name: currentRegion.name.trim(),
- geojson: geometry,
- is_public: false
- });
-
-
- if (result.success) {
- // Success! Update the region to be an API region
- const savedData = result.data;
-
- setCurrentRegion({
- source: 'api',
- data: currentRegion.data,
- name: savedData.name,
- id: savedData.id,
- isEditable: false
- });
- setSaveStatus('success');
-
- // Redisplay the saved region on the map to keep both visualizations
- if (mapContainer.current && currentRegion.data) {
- mapContainer.current.displayRegion(currentRegion.data);
- }
-
- // Refresh the regions list
- await doUserRegionFetch();
- await doUserRegionFetchPublic();
-
- // Call the callback if provided
- if (onRegionSave) {
- onRegionSave();
- }
- } else {
- // Save failed
- setSaveStatus('error');
- }
- } catch (error) {
- setSaveStatus('error');
- } finally {
- setIsSaving(false);
- }
- };
-
-
- return (
-
-
-
-
- {/* Legend overlaying bottom of map */}
-
-
-
-
-
-
Drawn Region (Web Mercator)
-
-
-
-
EPSG:5070 Projection (Equal Area - Perfect Rectangle)
-
-
-
-
-
-
-
-
- {isRegionDefineMode ? (
-
- {/* Compact header bar */}
-
-
- {/* Region selector dropdown - wider */}
- {userRegionAllAvailable && userRegionAllAvailable.length > 0 && (
-
{
- const regionId = e.target.value;
- if (!regionId) {
- handleMapAction({ type: 'CLEAR_SELECTION' });
- } else {
- handleMapAction({ type: 'SELECT_API', id: regionId });
- }
- }}
- className='text-sm px-2 py-1 w-64 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500'
- >
- Select existing region...
- {userRegionAllAvailable
- .filter(region => region && region.id && region.name)
- .map(region => (
-
- {region.name} {region.isPublic ? '(Public)' : ''}
-
- ))}
-
- )}
-
- {/* Delete button for selected region */}
- {currentRegion.source === 'api' && currentRegion.id && !userRegionAllAvailable.find(r => r.id === currentRegion.id)?.isPublic && (
-
{
- const region = userRegionAllAvailable.find(r => r.id === currentRegion.id);
- if (region && window.confirm(`Delete region "${region.name}"?`)) {
- await doUserRegionDelete(region);
- handleMapAction({ type: 'DELETE' });
- doUserRegionFetch();
- }
- }}
- className='p-1 text-red-600 hover:bg-red-50 rounded'
- title='Delete selected region'
- >
-
-
-
-
- )}
-
- {/* Input for new/uploaded regions */}
- {currentRegion.data && (currentRegion.source === 'drawn' || currentRegion.source === 'uploaded') && (
- <>
-
handleMapAction({ type: 'UPDATE_NAME', name: e.target.value })}
- placeholder='Enter region name'
- className={`text-sm px-2 py-1 border rounded-md focus:ring-indigo-500 focus:border-indigo-500 ${
- saveStatus === 'error' && !currentRegion.name.trim()
- ? 'border-red-500'
- : 'border-gray-300'
- }`}
- />
-
- {isSaving ? (
- <>
-
-
-
-
- Saving...
- >
- ) : saveStatus === 'success' ? (
- <>
-
-
-
- Saved!
- >
- ) : saveStatus === 'error' ? (
- <>
-
-
-
- Error
- >
- ) : (
- 'Save'
- )}
-
- >
- )}
-
-
- {/* Status text */}
-
- {saveStatus === 'success' ? (
-
-
-
-
-
Region saved successfully!
-
- ) : saveStatus === 'error' ? (
-
-
-
-
-
- {!currentRegion.name.trim() ? 'Please enter a region name' : 'Failed to save region'}
-
-
- ) : (
-
- {currentRegion.source === 'api'
- ? `Selected: ${currentRegion.name}`
- : currentRegion.source === 'drawn'
- ? 'Draw region on map'
- : currentRegion.source === 'uploaded'
- ? 'Uploaded region'
- : 'Draw or select a region'
- }
-
- )}
-
-
-
- ) : null}
-
-
- );
-});
diff --git a/src/app-pages/products/products.js b/src/app-pages/products/products.js
index 4812a70..0c09f64 100644
--- a/src/app-pages/products/products.js
+++ b/src/app-pages/products/products.js
@@ -10,7 +10,7 @@ import { CalendarIcon } from '@heroicons/react/24/solid';
import { connect } from 'redux-bundler-react';
import DateRangeSlider from './date-range-slider';
import ProductsTable from './products-table/products-table';
-import ProductsMap from './products-map/products-map';
+import CumulusMap from '../../app-components/cumulus-map';
import ButtonGroup from '../../app-components/button-group/button-group';
import ButtonGroupButton from '../../app-components/button-group/button-group-button';
import FilterPanel from './filter-panel';
@@ -112,11 +112,6 @@ export default connect(
[setFilterDateFrom, setFilterDateTo]
);
- // Handle custom region from map - region is now saved via API in ProductsMap
- const handleRegionSave = useCallback(() => {
- // Just close the map modal when region is saved
- //setShowRegionMap(false);
- }, []);
// open the download modal
const handleDownloadClick = useCallback(() => {
@@ -254,16 +249,22 @@ export default connect(
-
+
setShowRegionMap(!showRegionMap)}
- className='inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
+ title={!authIsLoggedIn ? 'Log in to use the map' : (showRegionMap ? 'Hide Map' : 'Show Map')}
+ className={`inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md ${
+ !authIsLoggedIn
+ ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
+ : 'text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
+ }`}
>
- Define Custom Download Region
+ {showRegionMap ? 'Hide' : 'Show'} Map
@@ -329,9 +330,8 @@ export default connect(
zIndex: 1
}}
>
-
{/* Resize handle */}
@@ -363,8 +363,8 @@ export default connect(
{activeView === 'table' ? (
) : (
-
)}