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 && ( + + )} + + {currentRegion.source === 'api' && currentRegion.id && !userRegionAllAvailable.find(r => r.id === currentRegion.id)?.isPublic && ( + + )} + + {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' + }`} + /> + + + )} +
+ +
+ {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 && ( - - )} - - {/* Delete button for selected region */} - {currentRegion.source === 'api' && currentRegion.id && !userRegionAllAvailable.find(r => r.id === currentRegion.id)?.isPublic && ( - - )} - - {/* 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' - }`} - /> - - - )} -
- - {/* 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( -
+
@@ -329,9 +330,8 @@ export default connect( zIndex: 1 }} > - {/* Resize handle */} @@ -363,8 +363,8 @@ export default connect( {activeView === 'table' ? ( ) : ( - )}