From b6e906f6391ca3ee127471834de340b7ba1e135f Mon Sep 17 00:00:00 2001
From: Carlos Felix <63804190+carlosfelix2@users.noreply.github.com>
Date: Mon, 9 Dec 2024 15:46:36 -0500
Subject: [PATCH] Search selects a tract on the map when searching for tract ID
---
client/package-lock.json | 6 +
client/package.json | 1 +
client/src/components/J40Map.tsx | 123 +++++++------
client/src/components/MapSearch/MapSearch.tsx | 167 +++++++++++-------
4 files changed, 167 insertions(+), 130 deletions(-)
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 (