diff --git a/react/jest.config.cjs b/react/jest.config.cjs index f95e0349..3d92089e 100644 --- a/react/jest.config.cjs +++ b/react/jest.config.cjs @@ -8,6 +8,7 @@ const esModules = [ 'react-leaflet', '@tacc/core-components', 'uuid', + 'react-leaflet-markercluster', ].join('|'); module.exports = { @@ -204,7 +205,10 @@ module.exports = { // timers: "real", // A map from regular expressions to paths to transformers - transform: { '^.+\\.(js|jsx|mjs)?$': 'babel-jest' }, + transform: { + '^.+\\.(js|jsx|mjs)?$': 'babel-jest', + '^.+\\.css$': 'jest-transform-stub', + }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation transformIgnorePatterns: [`/node_modules/(?!(${esModules}))`], diff --git a/react/package-lock.json b/react/package-lock.json index cbf64a94..05ee5392 100644 --- a/react/package-lock.json +++ b/react/package-lock.json @@ -11,7 +11,6 @@ "@babel/preset-env": "^7.20.2", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.0", - "@changey/react-leaflet-markercluster": "^4.0.0-rc1", "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", @@ -29,7 +28,8 @@ "eslint-plugin-react-hooks": "^4.6.0", "formik": "^2.4.5", "jwt-decode": "^4.0.0", - "leaflet": "^1.9.3", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "postcss-nesting": "^12.0.3", "prettier": "^2.7.1", "prop-types": "^15.8.1", @@ -38,7 +38,8 @@ "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-esri-leaflet": "^2.0.1", - "react-leaflet": "^4.2.0", + "react-leaflet": "^4.2.1", + "react-leaflet-markercluster": "^4.2.1", "react-query": "^3.39.3", "react-redux": "^8.0.2", "react-resize-detector": "^7.1.2", @@ -1986,22 +1987,6 @@ "tough-cookie": "^4.1.4" } }, - "node_modules/@changey/react-leaflet-markercluster": { - "version": "4.0.0-rc1", - "resolved": "https://registry.npmjs.org/@changey/react-leaflet-markercluster/-/react-leaflet-markercluster-4.0.0-rc1.tgz", - "integrity": "sha512-gS1lEQiQwyeI6Y6Wuxuqqffwywm7giQw4tbcqtJP8zyT5bc3AzW2/EVJGwWORYo/PLDdDnvOrpI+lUJy2UA5MQ==", - "dependencies": { - "@react-leaflet/core": "^2.0.0", - "leaflet": "^1.8.0", - "leaflet.markercluster": "^1.5.3", - "react-leaflet": "^4.0.0" - }, - "peerDependencies": { - "leaflet": "^1.8.0", - "leaflet.markercluster": "^1.5.3", - "react-leaflet": "^4.0.0" - } - }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -21206,6 +21191,22 @@ "react-dom": "^18.0.0" } }, + "node_modules/react-leaflet-markercluster": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet-markercluster/-/react-leaflet-markercluster-4.2.1.tgz", + "integrity": "sha512-lRJwCMGJVKXOKdP/dZIxszfHXjJaf8BpP2E+cNIYx5XxvqFj7NADG1HeK1nouNUgMcXVhqpZejiV6wPxwCibJw==", + "dependencies": { + "@react-leaflet/core": "^2.0.0", + "leaflet": "^1.8.0", + "leaflet.markercluster": "^1.5.3", + "react-leaflet": "^4.0.0" + }, + "peerDependencies": { + "leaflet": "^1.8.0", + "leaflet.markercluster": "^1.5.3", + "react-leaflet": "^4.0.0" + } + }, "node_modules/react-popper": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", diff --git a/react/package.json b/react/package.json index c45e8e6d..e52d3f52 100644 --- a/react/package.json +++ b/react/package.json @@ -35,7 +35,6 @@ "@babel/preset-env": "^7.20.2", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.0", - "@changey/react-leaflet-markercluster": "^4.0.0-rc1", "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", @@ -53,7 +52,8 @@ "eslint-plugin-react-hooks": "^4.6.0", "formik": "^2.4.5", "jwt-decode": "^4.0.0", - "leaflet": "^1.9.3", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "postcss-nesting": "^12.0.3", "prettier": "^2.7.1", "prop-types": "^15.8.1", @@ -62,7 +62,8 @@ "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-esri-leaflet": "^2.0.1", - "react-leaflet": "^4.2.0", + "react-leaflet": "^4.2.1", + "react-leaflet-markercluster": "^4.2.1", "react-query": "^3.39.3", "react-redux": "^8.0.2", "react-resize-detector": "^7.1.2", diff --git a/react/src/AppRouter.tsx b/react/src/AppRouter.tsx index 5a16e3c1..c820291f 100644 --- a/react/src/AppRouter.tsx +++ b/react/src/AppRouter.tsx @@ -7,14 +7,14 @@ import { Navigate, useLocation, } from 'react-router-dom'; -import * as ROUTES from './constants/routes'; -import MapProject from './pages/MapProject'; -import MainMenu from './pages/MainMenu'; -import Logout from './pages/Logout/Logout'; -import Login from './pages/Login/Login'; -import Callback from './pages/Callback/Callback'; -import StreetviewCallback from './pages/StreetviewCallback/StreetviewCallback'; -import { RootState } from './redux/store'; +import * as ROUTES from '@hazmapper/constants/routes'; +import MapProject from '@hazmapper/pages/MapProject'; +import MainMenu from '@hazmapper/pages/MainMenu'; +import Logout from '@hazmapper/pages/Logout/Logout'; +import Login from '@hazmapper/pages/Login/Login'; +import Callback from '@hazmapper/pages/Callback/Callback'; +import StreetviewCallback from '@hazmapper/pages/StreetviewCallback/StreetviewCallback'; +import { RootState } from '@hazmapper/redux/store'; import { isTokenValid } from '@hazmapper/utils/authUtils'; import { useBasePath } from '@hazmapper/hooks/environment'; diff --git a/react/src/components/FeatureFileTree/FeatureFileTree.tsx b/react/src/components/FeatureFileTree/FeatureFileTree.tsx index 8317cdaf..cfc7916a 100644 --- a/react/src/components/FeatureFileTree/FeatureFileTree.tsx +++ b/react/src/components/FeatureFileTree/FeatureFileTree.tsx @@ -74,7 +74,7 @@ const FeatureFileTree: React.FC = ({ return directoryIds; }; - // Have all direcotories be in 'expanded' (i.e. everything is expanded) + // Have all directories be in 'expanded' (i.e. everything is expanded) const expandedDirectories = getDirectoryNodeIds(fileNodeArray); const convertToTreeNode = (node: FeatureFileNode) => ({ @@ -194,4 +194,4 @@ const FeatureFileTree: React.FC = ({ ); }; -export default React.memo(FeatureFileTree); +export default FeatureFileTree; diff --git a/react/src/components/FeatureIcon/FeatureIcon.tsx b/react/src/components/FeatureIcon/FeatureIcon.tsx index e4bcaf66..b930bd1c 100644 --- a/react/src/components/FeatureIcon/FeatureIcon.tsx +++ b/react/src/components/FeatureIcon/FeatureIcon.tsx @@ -1,49 +1,16 @@ import React from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; -import { - faCameraRetro, - faVideo, - faClipboardList, - faMapMarkerAlt, - faDrawPolygon, - faCloud, - faBezierCurve, - faRoad, - faLayerGroup, - faQuestionCircle, -} from '@fortawesome/free-solid-svg-icons'; -import { FeatureType, FeatureTypeNullable } from '@hazmapper/types'; +import { FeatureTypeNullable } from '@hazmapper/types'; +import { featureTypeToIcon } from '@hazmapper/utils/featureIconUtil'; import styles from './FeatureIcon.module.css'; -const featureTypeToIcon: Record = { - // Asset types - image: faCameraRetro, - video: faVideo, - questionnaire: faClipboardList, - point_cloud: faCloud /* https://tacc-main.atlassian.net/browse/WG-391 */, - streetview: faRoad, - - // Geometry types - Point: faMapMarkerAlt, - LineString: faBezierCurve, - Polygon: faDrawPolygon, - MultiPoint: faMapMarkerAlt, - MultiLineString: faBezierCurve, - MultiPolygon: faDrawPolygon, - GeometryCollection: faLayerGroup, - - // Collection type - collection: faLayerGroup, -}; - interface Props { featureType: FeatureTypeNullable; } export const FeatureIcon: React.FC = ({ featureType }) => { - const icon = featureType ? featureTypeToIcon[featureType] : faQuestionCircle; + const icon = featureTypeToIcon(featureType); return ; }; diff --git a/react/src/components/Map/FitBoundsHandler.tsx b/react/src/components/Map/FitBoundsHandler.tsx new file mode 100644 index 00000000..6f5901ea --- /dev/null +++ b/react/src/components/Map/FitBoundsHandler.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useCallback, useRef } from 'react'; +import * as turf from '@turf/turf'; +import { useMap } from 'react-leaflet'; +import { FeatureCollection, Feature } from '@hazmapper/types'; +import { useFeatureSelection } from '@hazmapper/hooks'; +import { MAP_CONFIG } from './config'; +import L from 'leaflet'; + +const FitBoundsHandler: React.FC<{ + featureCollection: FeatureCollection; +}> = ({ featureCollection }) => { + const map = useMap(); + const hasFeatures = useRef(false); + const { selectedFeatureId } = useFeatureSelection(); + + const getBoundsFromFeature = useCallback( + (feature: FeatureCollection | Feature) => { + const bbox = turf.bbox(feature); + return [ + [bbox[1], bbox[0]] as [number, number], + [bbox[3], bbox[2]] as [number, number], + ]; + }, + [] + ); + + const zoomToFeature = useCallback( + (feature: Feature) => { + if (feature.geometry.type === 'Point') { + const coordinates = feature.geometry.coordinates; + const point = L.latLng(coordinates[1], coordinates[0]); + + map.setView(point, MAP_CONFIG.maxPointSelectedFeatureZoom, { + animate: false, + }); + } else { + const bounds = getBoundsFromFeature(feature); + map.fitBounds(bounds, { + maxZoom: MAP_CONFIG.maxFitBoundsSelectedFeatureZoom, + padding: [50, 50], + animate: false, + }); + } + }, + [map, getBoundsFromFeature] + ); + + // Handle initial bounds when features are loaded + useEffect(() => { + if ( + featureCollection.features.length && + !selectedFeatureId && + !hasFeatures.current + ) { + const bounds = getBoundsFromFeature(featureCollection); + map.fitBounds(bounds, { + maxZoom: MAP_CONFIG.maxFitBoundsInitialZoom, + padding: [50, 50], + }); + hasFeatures.current = true; + } + }, [map, featureCollection, selectedFeatureId, getBoundsFromFeature]); + + // Handle selected feature bounds + useEffect(() => { + if (selectedFeatureId) { + const activeFeature = featureCollection.features.find( + (f) => f.id === selectedFeatureId + ); + + if (activeFeature) { + zoomToFeature(activeFeature); + } + } + }, [map, selectedFeatureId, featureCollection, zoomToFeature]); + + return null; +}; + +export default FitBoundsHandler; diff --git a/react/src/components/Map/Map.module.css b/react/src/components/Map/Map.module.css new file mode 100644 index 00000000..59639f2e --- /dev/null +++ b/react/src/components/Map/Map.module.css @@ -0,0 +1,48 @@ +.markerCluster { + /* Size and shape */ + min-width: 36px; + min-height: 36px; + border-radius: 50%; + + /* Colors and borders */ + color: #ffffff; + background: var(--global-color-accent--normal); + border: 3px solid #ffffff; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + + /* Centering container */ + display: flex; + align-items: center; + justify-content: center; + + /* Text styling */ + font-family: var(--global-font-family--sans); + font-size: 14px; + font-weight: 500; + line-height: 1; + text-align: center; +} + +/* Style for the span containing the number */ +.markerCluster span { + /* Additional centering for the text itself */ + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + /* Offset slightly to account for border */ + margin-top: -1px; +} + +.marker { + border-radius: 50%; +} + +.markerContainer { + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} diff --git a/react/src/components/Map/Map.test.tsx b/react/src/components/Map/Map.test.tsx index baffacd2..88f8c8f8 100644 --- a/react/src/components/Map/Map.test.tsx +++ b/react/src/components/Map/Map.test.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { renderInTest } from '@hazmapper/test/testUtil'; import Map from './Map'; import { tileServerLayers } from '../../__fixtures__/tileServerLayerFixture'; import { featureCollection } from '../../__fixtures__/featuresFixture'; test('renders map', () => { - const { getByText } = render( + const { getByText } = renderInTest( ); expect(getByText(/Map/)).toBeDefined(); diff --git a/react/src/components/Map/Map.tsx b/react/src/components/Map/Map.tsx index 63486a0b..1558d424 100644 --- a/react/src/components/Map/Map.tsx +++ b/react/src/components/Map/Map.tsx @@ -1,29 +1,30 @@ -import React, { useEffect } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { MapContainer, ZoomControl, Marker, - Popup, TileLayer, WMSTileLayer, - useMap, + GeoJSON, } from 'react-leaflet'; +import MarkerClusterGroup from 'react-leaflet-markercluster'; import { TiledMapLayer } from 'react-esri-leaflet'; -import MarkerClusterGroup from '@changey/react-leaflet-markercluster'; -import { TileServerLayer, FeatureCollection } from '@hazmapper/types'; -import * as L from 'leaflet'; -import * as turf from '@turf/turf'; -import { LatLngTuple, MarkerCluster } from 'leaflet'; -import 'leaflet.markercluster'; -import 'leaflet/dist/leaflet.css'; +import { + TileServerLayer, + FeatureCollection, + Feature, + getFeatureType, + FeatureType, +} from '@hazmapper/types'; +import { useFeatureSelection } from '@hazmapper/hooks'; +import { MAP_CONFIG } from './config'; +import FitBoundsHandler from './FitBoundsHandler'; +import { createMarkerIcon, createClusterIcon } from './markerCreators'; +import { calculatePointCloudMarkerPosition } from './utils'; -const startingCenterPosition: LatLngTuple = [40, -80]; -const maxFitToBoundsZoom = 18; -const maxBounds: L.LatLngBoundsExpression = [ - [-90, -180], // Southwest coordinates - [90, 180], // Northeast coordinates -]; +import 'leaflet/dist/leaflet.css'; +import 'react-leaflet-markercluster/styles'; interface LeafletMapProps { /** @@ -37,30 +38,17 @@ interface LeafletMapProps { featureCollection: FeatureCollection; } -const ClusterMarkerIcon = (childCount: number) => { - return L.divIcon({ - html: `
${childCount}
`, - className: 'marker-cluster', - }); +const defaultGeoJsonOptions = { + style: { + color: '#3388ff', + weight: 3, + opacity: 1, + fillOpacity: 0.2, + }, }; -const FitBoundsOnInitialLoad = ({ - featureCollection, -}: { - featureCollection: FeatureCollection; -}) => { - const map = useMap(); - - useEffect(() => { - if (featureCollection.features.length) { - const bbox = turf.bbox(featureCollection); - const southWest: [number, number] = [bbox[1], bbox[0]]; - const northEast: [number, number] = [bbox[3], bbox[2]]; - map.fitBounds([southWest, northEast], { maxZoom: maxFitToBoundsZoom }); - } - }, [map, featureCollection]); - - return null; +const getFeatureStyle = (feature: any) => { + return feature.properties?.style || defaultGeoJsonOptions.style; }; /** @@ -69,26 +57,83 @@ const FitBoundsOnInitialLoad = ({ * Note this is not called Map as causes an issue with react-leaflet */ const LeafletMap: React.FC = ({ - baseLayers, + baseLayers = [], featureCollection, }) => { - const activeBaseLayers = baseLayers?.filter( - (layer) => layer.uiOptions.isActive + const { selectedFeatureId, setSelectedFeatureId } = useFeatureSelection(); + + const handleFeatureClick = useCallback( + (feature: any) => { + setSelectedFeatureId(feature.id); + + //TODO handle clicking on streetview https://tacc-main.atlassian.net/browse/WG-392 + }, + [selectedFeatureId] ); - const pointGeometryFeatures = featureCollection.features.filter( - (f) => f.geometry.type === 'Point' + const activeBaseLayers = useMemo( + () => baseLayers.filter((layer) => layer.uiOptions.isActive), + [baseLayers] ); + interface FeatureAccumulator { + generalGeoJsonFeatures: Feature[] /* non-point features, includes point cloud outlines */; + markerFeatures: Feature[]; + streetviewFeatures: Feature[]; + } + + // Initial accumulator state + const initialAccumulator: FeatureAccumulator = { + generalGeoJsonFeatures: [], + markerFeatures: [], + streetviewFeatures: [], + }; + + const { + generalGeoJsonFeatures, + markerFeatures, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + streetviewFeatures /* Add streetview support https://tacc-main.atlassian.net/browse/WG-392 */, + } = useMemo(() => { + return featureCollection.features.reduce( + (accumulator, feature: Feature) => { + if (feature.geometry.type === FeatureType.Point) { + accumulator.markerFeatures.push(feature); + } else { + if (getFeatureType(feature) === FeatureType.PointCloud) { + // Add a marker at the calculated position + const markerPosition = calculatePointCloudMarkerPosition( + feature.geometry + ); + const pointCloudMarker: Feature = { + ...feature, + geometry: { + type: 'Point', + coordinates: [markerPosition.lng, markerPosition.lat], + }, + }; + + accumulator.markerFeatures.push(pointCloudMarker); + // Also keep the original geometry for rendering + accumulator.generalGeoJsonFeatures.push(feature); + } else { + accumulator.generalGeoJsonFeatures.push(feature); + } + } + return accumulator; + }, + initialAccumulator + ); + }, [featureCollection.features]); return ( {activeBaseLayers?.map((layer) => layer.type === 'wms' ? ( @@ -113,25 +158,49 @@ const LeafletMap: React.FC = ({ /> ) )} + {/* General GeoJSON Features (including point cloud geometries) */} + {generalGeoJsonFeatures.map((feature) => ( + getFeatureStyle(feature)} + eventHandlers={{ + click: () => handleFeatureClick(feature), + contextmenu: () => handleFeatureClick(feature), + }} + /> + ))} + {/* Marker Features with Clustering (also includes point cloud markers) */} - ClusterMarkerIcon(cluster.getChildCount()) + zIndexOffset={1} + iconCreateFunction={createClusterIcon} + chunkedLoading={true} + animate={true} + maxFitBoundsSelectedFeatureZoom={ + MAP_CONFIG.maxFitBoundsSelectedFeatureZoom } + spiderifyOnHover={true} + spiderfyOnMaxZoom={true} + spiderfyOnZoom={MAP_CONFIG.maxPointSelectedFeatureZoom} + zoomToBoundsOnClick={true} > - {pointGeometryFeatures.map((f) => { - const geometry = f.geometry as GeoJSON.Point; - + {markerFeatures.map((feature) => { + const geometry = feature.geometry as GeoJSON.Point; return ( - {f.id} - + eventHandlers={{ + click: () => handleFeatureClick(feature), + contextmenu: () => handleFeatureClick(feature), + }} + /> ); })} - + {/* Handles zooming to a specific feature or to all features */} + ); diff --git a/react/src/components/Map/config.ts b/react/src/components/Map/config.ts new file mode 100644 index 00000000..35381d70 --- /dev/null +++ b/react/src/components/Map/config.ts @@ -0,0 +1,14 @@ +import { LatLngTuple } from 'leaflet'; + +export const MAP_CONFIG = { + startingCenter: [40, -80] as LatLngTuple, + minZoom: 2, // 2 typically prevents zooming out too far to see multiple earths + maxZoom: 24, // Maximum possible detail + maxFitBoundsInitialZoom: 18, + maxFitBoundsSelectedFeatureZoom: 18, + maxPointSelectedFeatureZoom: 15, // Single agreed-upon zoom level for points/cluster + maxBounds: [ + [-90, -180], // Southwest coordinates + [90, 180], // Northeast coordinates + ] as L.LatLngBoundsExpression, +} as const; diff --git a/react/src/components/Map/markerCreators.ts b/react/src/components/Map/markerCreators.ts new file mode 100644 index 00000000..9c8f0e0b --- /dev/null +++ b/react/src/components/Map/markerCreators.ts @@ -0,0 +1,170 @@ +import L, { MarkerCluster } from 'leaflet'; +import { getFeatureType, FeatureType } from '@hazmapper/types'; +import { featureTypeToIcon } from '@hazmapper/utils/featureIconUtil'; +import styles from './Map.module.css'; + +const defaultConfig = { + // Regular marker settings + size: 36, // Regular marker size (36x36px) + iconSize: 20, // Icon size within regular marker (20x20px) + fillColor: 'var(--global-color-accent--normal)', + backgroundColor: '#ffffff', + borderWidth: 2, + borderColor: '#ffffff', + + // Cluster marker settings + clusterSize: 36, + clusterBorderWidth: 3, + clusterFontSize: 14, +}; + +const _createCircleMarker = (customStyle = {}) => { + const style = { + radius: 8, + fillColor: defaultConfig.fillColor, + strokeColor: defaultConfig.borderColor, + strokeWidth: defaultConfig.borderWidth, + opacity: 1, + fillOpacity: 0.8, + ...customStyle, + }; + + const padding = style.strokeWidth; + const totalSize = style.radius * 2 + padding * 2; + const center = totalSize / 2; + + return L.divIcon({ + html: ` + + `, + className: styles.marker, + iconSize: [totalSize, totalSize], + iconAnchor: [center, center], + }); +}; + +const _createIconMarker = ( + iconClass, + customStyle: { + color?: string; + backgroundColor?: string; + size?: number; + iconSize?: number; + } = {} +) => { + const style = { + color: customStyle.color ?? defaultConfig.fillColor, + backgroundColor: + customStyle.backgroundColor ?? defaultConfig.backgroundColor, + size: customStyle.size ?? defaultConfig.size, + iconSize: customStyle.iconSize ?? defaultConfig.iconSize, + }; + + return L.divIcon({ + html: ` +
+ +
+ `, + className: styles.marker, + iconSize: [style.size, style.size], + iconAnchor: [style.size / 2, style.size / 2], + }); +}; + +const _createFeatureTypeMarker = (featureType: FeatureType) => { + const icon = featureTypeToIcon(featureType); + const iconPath = icon.icon[4]; + const viewBoxWidth = icon.icon[0]; + const viewBoxHeight = icon.icon[1]; + + return L.divIcon({ + html: ` +
+ + + +
+ `, + className: styles.marker, + iconSize: [defaultConfig.size, defaultConfig.size], + iconAnchor: [defaultConfig.size / 2, defaultConfig.size / 2], + }); +}; + +/** + * Creates a marker based on feature type + */ +export const createClusterIcon = (cluster: MarkerCluster) => { + return L.divIcon({ + html: `${cluster.getChildCount()}`, + className: styles.markerCluster, + iconSize: L.point( + defaultConfig.clusterSize, + defaultConfig.clusterSize, + true + ), + }); +}; + +/** + * Creates the appropriate marker icon based on feature and its properties + * + * Decision flow: + * 1. If feature has custom style: + * - With FA icon -> Use Font Awesome marker + * - Without FA icon -> Use circle marker with custom style + * 2. If feature is Point type -> Use default circle marker + * 3. Otherwise -> Use feature type specific marker (i.e. appropriate fa icon) + * + */ +export const createMarkerIcon = (feature) => { + const customStyle = feature.properties?.style; + if (customStyle) { + if (customStyle.faIcon) { + return _createIconMarker(customStyle.faIcon, customStyle); + } + return _createCircleMarker(customStyle); + } + + const featureType = getFeatureType(feature); + if (featureType === FeatureType.Point) { + return _createCircleMarker(); + } + + return _createFeatureTypeMarker(featureType); +}; diff --git a/react/src/components/Map/utils.ts b/react/src/components/Map/utils.ts new file mode 100644 index 00000000..a485e084 --- /dev/null +++ b/react/src/components/Map/utils.ts @@ -0,0 +1,16 @@ +import { LatLng } from 'leaflet'; +import * as turf from '@turf/turf'; +import GeoJSON from 'geojson'; + +/** + * Calculates the top-left corner position of a point cloud polygon + */ +export function calculatePointCloudMarkerPosition( + geometry: GeoJSON.Geometry +): LatLng { + const bbox = turf.bbox(geometry); + // bbox format is [minX, minY, maxX, maxY] + const north = bbox[3]; // maxY + const west = bbox[0]; // minX + return new LatLng(north, west); +} diff --git a/react/src/hooks/features/index.ts b/react/src/hooks/features/index.ts index 0026c541..af9f8dab 100644 --- a/react/src/hooks/features/index.ts +++ b/react/src/hooks/features/index.ts @@ -1,3 +1,7 @@ export { useDeleteFeature } from './useDeleteFeature'; -export { useFeatures, useCurrentFeatures } from './useFeatures'; +export { + useFeatures, + useCurrentFeatures, + KEY_USE_FEATURES, +} from './useFeatures'; export { useFeatureSelection } from './useFeatureSelection'; diff --git a/react/src/hooks/features/useFeatures.ts b/react/src/hooks/features/useFeatures.ts index 6c06e802..9d01a9af 100644 --- a/react/src/hooks/features/useFeatures.ts +++ b/react/src/hooks/features/useFeatures.ts @@ -24,13 +24,13 @@ export const useFeatures = ({ endpoint += `?assetType=${assetTypes.join(',')}`; } - /* Expensive to fetch and process so we only fetch when updated */ const defaultQueryOptions = { - staleTime: Infinity, - cacheTime: Infinity, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, + /* Expensive to fetch and process so we only fetch when updated */ + staleTime: Infinity /* "" */, + cacheTime: Infinity /* "" */, + refetchOnWindowFocus: false /* "" */, + refetchOnMount: false /* "" */, + refetchOnReconnect: false /* "" */, }; const query = useGet({ diff --git a/react/src/index.css b/react/src/index.css index 27f096d5..82a2ff29 100644 --- a/react/src/index.css +++ b/react/src/index.css @@ -3,6 +3,10 @@ /* prettier-ignore */ @import url('https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css') layer(foundation); +/* needed for custom icons on map (to support taggit) */ +@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css') +layer(foundation); + @import url('@tacc/core-styles/dist/core-styles.base.css') layer(base); @import url('@tacc/core-styles/dist/core-styles.portal.css') layer(base); diff --git a/react/src/pages/MapProject/MapProject.module.css b/react/src/pages/MapProject/MapProject.module.css index e1489c8b..29ec1a4f 100644 --- a/react/src/pages/MapProject/MapProject.module.css +++ b/react/src/pages/MapProject/MapProject.module.css @@ -13,15 +13,6 @@ height: 100vh; } -.topNavbar { - background-color: black; - color: white; - height: var(--hazmapper-header-navbar-height); - min-height: var(--hazmapper-header-navbar-height); - display: flex; - align-items: center; -} - .mapControlBar { background-color: white; height: var(--hazmapper-control-bar-height); diff --git a/react/src/pages/MapProject/MapProject.tsx b/react/src/pages/MapProject/MapProject.tsx index 17dff4de..abc0f0df 100644 --- a/react/src/pages/MapProject/MapProject.tsx +++ b/react/src/pages/MapProject/MapProject.tsx @@ -1,6 +1,9 @@ -import React from 'react'; -import { useState } from 'react'; -import { useLocation } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; +import { useQueryClient } from 'react-query'; + +import { Message, LoadingSpinner } from '@tacc/core-components'; + import Map from '@hazmapper/components/Map'; import AssetsPanel from '@hazmapper/components/AssetsPanel'; import AssetDetail from '@hazmapper/components/AssetDetail'; @@ -11,15 +14,14 @@ import { useProject, useTileServers, useFeatureSelection, + KEY_USE_FEATURES, } from '@hazmapper/hooks'; -import { useParams } from 'react-router-dom'; -import styles from './MapProject.module.css'; import MapProjectNavBar from '@hazmapper/components/MapProjectNavBar'; import Filters from '@hazmapper/components/FiltersPanel/Filter'; import { assetTypeOptions } from '@hazmapper/components/FiltersPanel/Filter'; import { Project } from '@hazmapper/types'; -import { Message, LoadingSpinner } from '@tacc/core-components'; import HeaderNavBar from '@hazmapper/components/HeaderNavBar'; +import styles from './MapProject.module.css'; interface MapProjectProps { /** @@ -34,6 +36,7 @@ interface MapProjectProps { */ const MapProject: React.FC = ({ isPublicView = false }) => { const { projectUUID } = useParams(); + const queryClient = useQueryClient(); const { data: activeProject, @@ -45,6 +48,14 @@ const MapProject: React.FC = ({ isPublicView = false }) => { options: { enabled: !!projectUUID }, }); + // Clear feature queries when changing projects to prevent stale features from + // briefly appearing and causing incorrect map bounds/zoom during navigation + useEffect(() => { + return () => { + queryClient.removeQueries([KEY_USE_FEATURES]); + }; + }, [projectUUID, queryClient]); + if (isLoading) { /* TODO_REACT show error and improve spinner https://tacc-main.atlassian.net/browse/WG-260*/ return ( @@ -162,7 +173,7 @@ const LoadedMapProject: React.FC = ({
- MapTopControlBar + MapTopControlBar TODO https://tacc-main.atlassian.net/browse/WG-260 {loading &&
loading
}
diff --git a/react/src/types/feature.ts b/react/src/types/feature.ts index 4915926d..9ac46fb9 100644 --- a/react/src/types/feature.ts +++ b/react/src/types/feature.ts @@ -1,18 +1,45 @@ import { GeoJsonProperties, Geometry } from 'geojson'; -// Define asset types from GeoApi -export type AssetType = - | 'image' - | 'video' - | 'questionnaire' - | 'point_cloud' - | 'streetview'; +// Create the constant object for AssetType +export const AssetType = { + Image: 'image', + Video: 'video', + Questionnaire: 'questionnaire', + PointCloud: 'point_cloud', + Streetview: 'streetview', +} as const; + +export type AssetType = (typeof AssetType)[keyof typeof AssetType]; -// Define all possible geometry types from GeoJSON (e.g. "Point", "MultiPoint", "Polygon" etc) export type GeoJSONGeometryType = Geometry['type']; -// Combined feature type that includes all possibilities for a feature -export type FeatureType = AssetType | GeoJSONGeometryType | 'collection'; +// Define all possible geometry types from GeoJSON +const createGeoJSONTypes = () => { + const types: Record = { + Point: 'Point', + MultiPoint: 'MultiPoint', + LineString: 'LineString', + MultiLineString: 'MultiLineString', + Polygon: 'Polygon', + MultiPolygon: 'MultiPolygon', + GeometryCollection: 'GeometryCollection', + }; + return types; +}; + +export const FeatureType = { + // GeoJSON types + ...createGeoJSONTypes(), + + // Asset types + ...AssetType, + + // Collection type (i.e., more than 1 asset; + // not used/created in frontend but possible to create using Geoapi) + Collection: 'collection', +} as const; + +export type FeatureType = (typeof FeatureType)[keyof typeof FeatureType]; export type FeatureTypeNullable = FeatureType | undefined; @@ -30,7 +57,18 @@ export interface Asset { } /** - * Geoapi feature which is a GeoJSON Feature object with additional `id`, `project_id`, `properties`, `styles`, and `assets` properties. + * A GeoAPI Feature extends a GeoJSON Feature with some additions. + * + * From GeoJSON standard (https://geojson.org/): + * • type + * • geometry + * * properties + * + * Additons for GeoAPI/hazmapper: + * • id + * • project_id + * • styles + * • assets */ export interface Feature { /** @@ -68,11 +106,11 @@ export interface Feature { */ export function getFeatureType(feature: Feature): FeatureType { if (feature.assets.length === 1) { - /* If there is only one asset, returns the type of that asset (i.e. "image", "video", "point_cloud", or "streetview".)*/ + /* If there is only one asset, returns the type of that asset (i.e. AssetType: "image", "video", "point_cloud", or "streetview".)*/ return feature.assets[0].asset_type; } else if (feature.assets.length > 1) { /* multiple assets */ - return 'collection'; + return FeatureType.Collection; } else { /* else we rely on geojson geometry type */ return feature.geometry.type; diff --git a/react/src/types/react-leaflet-markercluster.d.ts b/react/src/types/react-leaflet-markercluster.d.ts deleted file mode 100644 index fe7f1f5b..00000000 --- a/react/src/types/react-leaflet-markercluster.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '@changey/react-leaflet-markercluster'; diff --git a/react/src/utils/featureIconUtil.ts b/react/src/utils/featureIconUtil.ts new file mode 100644 index 00000000..c2a17554 --- /dev/null +++ b/react/src/utils/featureIconUtil.ts @@ -0,0 +1,52 @@ +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { + faCameraRetro, + faVideo, + faClipboardList, + faMapMarkerAlt, + faDrawPolygon, + faCloud, + faBezierCurve, + faRoad, + faLayerGroup, + faQuestionCircle, +} from '@fortawesome/free-solid-svg-icons'; + +import { FeatureType, FeatureTypeNullable } from '@hazmapper/types'; + +const FeatureTypeToIconMap: Record = { + // Asset types + image: faCameraRetro, + video: faVideo, + questionnaire: faClipboardList, + point_cloud: faCloud /* https://tacc-main.atlassian.net/browse/WG-391 */, + streetview: faRoad, + + // Geometry types + Point: faMapMarkerAlt, + LineString: faBezierCurve, + Polygon: faDrawPolygon, + MultiPoint: faMapMarkerAlt, + MultiLineString: faBezierCurve, + MultiPolygon: faDrawPolygon, + GeometryCollection: faLayerGroup, + + // Collection type + collection: faLayerGroup, +}; + +/** + * Maps a feature type to its font awesome icon. + * + * Returns faQuestionCircle if: + * - feature is null + * - feature is not found in FeatureTypeToIconMap + */ +export const featureTypeToIcon = ( + featureType: FeatureTypeNullable +): IconDefinition => { + if (!featureType || !(featureType in FeatureTypeToIconMap)) { + return faQuestionCircle; + } + return FeatureTypeToIconMap[featureType]; +};