diff --git a/client/package-lock.json b/client/package-lock.json index 98143395..4a681388 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -3203,6 +3203,12 @@ "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", "integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==" }, + "@types/js-search": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@types/js-search/-/js-search-1.4.4.tgz", + "integrity": "sha512-NYIBuSRTi2h6nLne0Ygx78BZaiT/q0lLU7YSkjOrDJWpSx6BioIZA/i2GZ+WmMUzEQs2VNIWcXRRAqisrG3ZNA==", + "dev": true + }, "@types/json-patch": { "version": "0.0.30", "resolved": "https://registry.npmjs.org/@types/json-patch/-/json-patch-0.0.30.tgz", diff --git a/client/package.json b/client/package.json index aebfd24f..3412f584 100644 --- a/client/package.json +++ b/client/package.json @@ -39,6 +39,7 @@ "@testing-library/react": "^11.2.7", "@types/d3-ease": "^3.0.0", "@types/jest": "^26.0.24", + "@types/js-search": "^1.4.4", "@types/maplibre-gl": "^1.14.0", "@types/node": "^15.14.9", "@types/react": "^17.0.41", diff --git a/client/src/components/J40Map.tsx b/client/src/components/J40Map.tsx index adc249b9..5ba868a2 100644 --- a/client/src/components/J40Map.tsx +++ b/client/src/components/J40Map.tsx @@ -57,6 +57,13 @@ export interface IDetailViewInterface { properties: constants.J40Properties, }; +export interface IMapFeature { + id: string; + geometry: any; + properties: any; + type: string; +} + const J40Map = ({location}: IJ40Interface) => { /** * Initializes the zoom, and the map's center point (lat, lng) via the URL hash #{z}/{lat}/{long} @@ -108,17 +115,60 @@ const J40Map = ({location}: IJ40Interface) => { const zoomLatLngHash = mapRef.current?.getMap()._hash._getCurrentHash(); /** - * This function will return the bounding box of the current map. Comment in when needed. - * { - * _ne: {lng:number, lat:number} - * _sw: {lng:number, lat:number} - * } - * @returns {LngLatBounds} + * Selects the provided feature on the map. + * @param feature the feature to select */ - // const getCurrentMapBoundingBox = () => { - // return mapRef.current ? console.log('mapRef getBounds(): ', mapRef.current.getMap().getBounds()) : null; - // }; + const selectFeatureOnMap = (feature: IMapFeature) => { + if (feature) { + // Get the current selected feature's bounding box: + const [minLng, minLat, maxLng, maxLat] = bbox(feature); + // Set the selectedFeature ID + if (feature.id !== selectedFeatureId) { + setSelectedFeature(feature); + } else { + setSelectedFeature(undefined); + } + + // Go to the newly selected feature (as long as it's not an Alaska Point) + goToPlace([ + [minLng, minLat], + [maxLng, maxLat], + ]); + + /** + * The following logic is used for the popup for the fullscreen feature + */ + // Create a new viewport using the current viewport dimnesions: + const newViewPort = new WebMercatorViewport({height: viewport.height!, width: viewport.width!}); + + // Fit the viewport to the new bounds and return a long, lat and zoom: + const {longitude, latitude, zoom} = newViewPort.fitBounds( + [ + [minLng, minLat], + [maxLng, maxLat], + ], + { + padding: 40, + }, + ); + + // Save the popupInfo + const popupInfo = { + longitude: longitude, + latitude: latitude, + zoom: zoom, + properties: feature.properties, + }; + + // Update the DetailedView state variable with the new popupInfo object: + setDetailViewData(popupInfo); + + /** + * End Fullscreen feature specific logic + */ + } + }; /** * This onClick event handler will listen and handle clicks on the map. It will listen for clicks on the @@ -174,58 +224,7 @@ const J40Map = ({location}: IJ40Interface) => { // @ts-ignore const feature = event.features && event.features[0]; - if (feature) { - // Get the current selected feature's bounding box: - const [minLng, minLat, maxLng, maxLat] = bbox(feature); - - - // Set the selectedFeature ID - if (feature.id !== selectedFeatureId) { - setSelectedFeature(feature); - } else { - setSelectedFeature(undefined); - } - - - // Go to the newly selected feature (as long as it's not an Alaska Point) - goToPlace([ - [minLng, minLat], - [maxLng, maxLat], - ]); - - - /** - * The following logic is used for the popup for the fullscreen feature - */ - // Create a new viewport using the current viewport dimnesions: - const newViewPort = new WebMercatorViewport({height: viewport.height!, width: viewport.width!}); - - // Fit the viewport to the new bounds and return a long, lat and zoom: - const {longitude, latitude, zoom} = newViewPort.fitBounds( - [ - [minLng, minLat], - [maxLng, maxLat], - ], - { - padding: 40, - }, - ); - - // Save the popupInfo - const popupInfo = { - longitude: longitude, - latitude: latitude, - zoom: zoom, - properties: feature.properties, - }; - - // Update the DetailedView state variable with the new popupInfo object: - setDetailViewData(popupInfo); - - /** - * End Fullscreen feature specific logic - */ - } + selectFeatureOnMap(feature); } }; @@ -390,7 +389,7 @@ const J40Map = ({location}: IJ40Interface) => { {/* This is the first overlayed row on the map: Search and Geolocation */}
- + {/* Geolocate Icon */}
diff --git a/client/src/components/MapSearch/MapSearch.tsx b/client/src/components/MapSearch/MapSearch.tsx index 681332ea..4966b9c0 100644 --- a/client/src/components/MapSearch/MapSearch.tsx +++ b/client/src/components/MapSearch/MapSearch.tsx @@ -4,6 +4,8 @@ import {LngLatBoundsLike} from 'maplibre-gl'; import {useIntl} from 'gatsby-plugin-intl'; import {Search} from '@trussworks/react-uswds'; import {useWindowSize} from 'react-use'; +import {RefObject} from 'react'; +import {MapRef} from 'react-map-gl'; import * as JsSearch from 'js-search'; import * as constants from '../../data/constants'; @@ -14,19 +16,17 @@ import * as EXPLORE_COPY from '../../data/copy/explore'; interface IMapSearch { goToPlace(bounds: LngLatBoundsLike):void; + mapRef:RefObject; + selectFeatureOnMap: (feature: any) => void; } -interface ISearchResult { - addresstype: string; - lat: string; - lon: string; - boundingbox: string[]; - type: string; - // eslint-disable-next-line camelcase - place_rank: number; +interface ISearchTractRecord { + GEOID10: string; + INTPTLAT10: string; + INTPTLON10: string; } -const MapSearch = ({goToPlace}:IMapSearch) => { +const MapSearch = ({goToPlace, mapRef, selectFeatureOnMap}:IMapSearch) => { // State to hold if the search results are empty or not: const [isSearchResultsNull, setIsSearchResultsNull] = useState(false); const intl = useIntl(); @@ -42,7 +42,7 @@ const MapSearch = ({goToPlace}:IMapSearch) => { */ const {width, height} = useWindowSize(); const [placeholderText, setPlaceholderText]= useState(EXPLORE_COPY.MAP.SEARCH_PLACEHOLDER); - const [tractSearch, setTractSearch] = useState(null); + const [tractSearch, setTractSearch] = useState(null); /** * Gets the tract search data and loads in the state. @@ -80,76 +80,88 @@ const MapSearch = ({goToPlace}:IMapSearch) => { /** * Searchs for a given Census tract ID. * @param {string} tract the 11 digit tract ID as a string - * @return {Array} an array of one search result, or null if no result found */ - const searchForTract = (tract: string): [ISearchResult] | [] => { + const searchForTract = async (tract: string) => { + /** + * Wait for the map to be done loading and moving. + * @param {function()} callback the callback to run after the map is ready + */ + const waitforMap = (callback: () => void): void => { + const isMapReady = !!mapRef.current && + mapRef.current.getMap().isStyleLoaded() && + mapRef.current.getMap().isSourceLoaded(constants.HIGH_ZOOM_SOURCE_NAME); + if (isMapReady) { + callback(); + } else { + setTimeout(() => waitforMap(callback), 200); + } + }; + + setIsSearchResultsNull(true); + // We create a bounding box just to get the tract in the view box. // The size is not important. - const BOUNDING_BOX_SIZE_DD = 0.2; + const BOUNDING_BOX_SIZE_DD = 0.1; if (tractSearch) { // Convert 10 digit tracts to 11. const searchTerm = tract.length == 10 ? '0' + tract : tract; const result = tractSearch.search(searchTerm); if (result.length > 0) { - const lat = Number(result[0].INTPTLAT10); - const lon = Number(result[0].INTPTLON10); - return [{ - addresstype: 'tract', - boundingbox: [ - (lat - (BOUNDING_BOX_SIZE_DD / 2)).toString(), - (lat + (BOUNDING_BOX_SIZE_DD / 2)).toString(), - (lon - (BOUNDING_BOX_SIZE_DD / 2)).toString(), - (lon + (BOUNDING_BOX_SIZE_DD / 2)).toString(), - ], - lat: result[0].INTPTLAT10, - lon: result[0].INTPTLON10, - type: 'tract', - place_rank: 1, - }]; + const searchTractRecord = result[0] as ISearchTractRecord; + const lat = Number(searchTractRecord.INTPTLAT10); + const lon = Number(searchTractRecord.INTPTLON10); + const boundingBox = [ + (lat - (BOUNDING_BOX_SIZE_DD / 2)).toString(), + (lat + (BOUNDING_BOX_SIZE_DD / 2)).toString(), + (lon - (BOUNDING_BOX_SIZE_DD / 2)).toString(), + (lon + (BOUNDING_BOX_SIZE_DD / 2)).toString(), + ]; + const [latMin, latMax, longMin, longMax] = boundingBox; + setIsSearchResultsNull(false); + + // Now move the map and select the tract. + goToPlace([[Number(longMin), Number(latMin)], [Number(longMax), Number(latMax)]]); + waitforMap(() => { + // Set up a one-shot event handler to fire when the flyTo arrives at its destination. Once the + // tract is in view of the map. mpRef.current will always be valid here... + mapRef.current?.getMap().once('idle', () => { + const geoidSearchResults = mapRef.current?.getMap().querySourceFeatures(constants.HIGH_ZOOM_SOURCE_NAME, { + sourceLayer: constants.SCORE_SOURCE_LAYER, + validate: true, + filter: ['==', constants.GEOID_PROPERTY, searchTerm], + }); + if (geoidSearchResults && geoidSearchResults.length > 0) { + selectFeatureOnMap(geoidSearchResults[0]); + } + }); + }); } } - return []; }; - /* - onSearchHandler will - 1. extract the search term from the input field - 2. Determine if the search term is a Census Tract or not. - 3. If it is a Census Tract, it will search the tract table for a bounding box. - 4. If it is NOT a Census Tract, it will fetch data from the API and return the - results as JSON and results to US only. If data is valid, destructure the - boundingBox values from the search results. - 4. Pan the map to that location - */ - const onSearchHandler = async (event: React.FormEvent) => { - event.preventDefault(); - event.stopPropagation(); - - const searchTerm = (event.currentTarget.elements.namedItem('search') as HTMLInputElement).value; - let searchResults = null; - - // If the search term a Census tract - const isTract = /^\d{10,11}$/.test(searchTerm); - if (isTract) { - setIsSearchResultsNull(false); - searchResults = searchForTract(searchTerm); - } else { - searchResults = await fetch( - `https://nominatim.openstreetmap.org/search?q=${searchTerm}&format=json&countrycodes=us`, - { - mode: 'cors', - }) - .then((response) => { - if (!response.ok) { - throw new Error('Network response was not OK'); - } - return response.json(); - }) - .catch((error) => { - console.error('There has been a problem with your fetch operation:', error); - }); - console.log('Nominatum search results: ', searchResults); - } + /** + * Searchs for a given location such as address, zip, etc. This method will + * will fetch data from the PSM API and return the results as JSON and + * results to US only. If the data is valid, destructure the boundingBox + * values from the search results. Finally, is pans the map to the location. + * @param {string} searchTerm the location to search for + */ + const searchForLocation = async (searchTerm: string) => { + const searchResults = await fetch( + `https://nominatim.openstreetmap.org/search?q=${searchTerm}&format=json&countrycodes=us`, + { + mode: 'cors', + }) + .then((response) => { + if (!response.ok) { + throw new Error('Network response was not OK'); + } + return response.json(); + }) + .catch((error) => { + console.error('There has been a problem with your fetch operation:', error); + }); + console.log('Nominatum search results: ', searchResults); // If results are valid, set isSearchResultsNull to false and pan map to location: if (searchResults && searchResults.length > 0) { @@ -161,6 +173,25 @@ const MapSearch = ({goToPlace}:IMapSearch) => { } }; + /** + Searches for a given search term upon clicking on the search button. + @param {React.FormEvent} event the click event + */ + const onSearchHandler = async (event: React.FormEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const searchTerm = (event.currentTarget.elements.namedItem('search') as HTMLInputElement).value; + + // If the search term a Census tract + const isTract = /^\d{10,11}$/.test(searchTerm); + if (isTract) { + searchForTract(searchTerm); + } else { + searchForLocation(searchTerm); + } + }; + return (