Search selects a tract on the map when searching for tract ID

This commit is contained in:
Carlos Felix 2024-12-09 15:46:36 -05:00 committed by Carlos Felix
commit b6e906f639
4 changed files with 167 additions and 130 deletions

View file

@ -3203,6 +3203,12 @@
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz",
"integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==" "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": { "@types/json-patch": {
"version": "0.0.30", "version": "0.0.30",
"resolved": "https://registry.npmjs.org/@types/json-patch/-/json-patch-0.0.30.tgz", "resolved": "https://registry.npmjs.org/@types/json-patch/-/json-patch-0.0.30.tgz",

View file

@ -39,6 +39,7 @@
"@testing-library/react": "^11.2.7", "@testing-library/react": "^11.2.7",
"@types/d3-ease": "^3.0.0", "@types/d3-ease": "^3.0.0",
"@types/jest": "^26.0.24", "@types/jest": "^26.0.24",
"@types/js-search": "^1.4.4",
"@types/maplibre-gl": "^1.14.0", "@types/maplibre-gl": "^1.14.0",
"@types/node": "^15.14.9", "@types/node": "^15.14.9",
"@types/react": "^17.0.41", "@types/react": "^17.0.41",

View file

@ -57,6 +57,13 @@ export interface IDetailViewInterface {
properties: constants.J40Properties, properties: constants.J40Properties,
}; };
export interface IMapFeature {
id: string;
geometry: any;
properties: any;
type: string;
}
const J40Map = ({location}: IJ40Interface) => { const J40Map = ({location}: IJ40Interface) => {
/** /**
* Initializes the zoom, and the map's center point (lat, lng) via the URL hash #{z}/{lat}/{long} * 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(); const zoomLatLngHash = mapRef.current?.getMap()._hash._getCurrentHash();
/** /**
* This function will return the bounding box of the current map. Comment in when needed. * Selects the provided feature on the map.
* { * @param feature the feature to select
* _ne: {lng:number, lat:number}
* _sw: {lng:number, lat:number}
* }
* @returns {LngLatBounds}
*/ */
// const getCurrentMapBoundingBox = () => { const selectFeatureOnMap = (feature: IMapFeature) => {
// return mapRef.current ? console.log('mapRef getBounds(): ', mapRef.current.getMap().getBounds()) : null; 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 * 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 // @ts-ignore
const feature = event.features && event.features[0]; const feature = event.features && event.features[0];
if (feature) { selectFeatureOnMap(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
*/
}
} }
}; };
@ -390,7 +389,7 @@ const J40Map = ({location}: IJ40Interface) => {
{/* This is the first overlayed row on the map: Search and Geolocation */} {/* This is the first overlayed row on the map: Search and Geolocation */}
<div className={styles.mapHeaderRow}> <div className={styles.mapHeaderRow}>
<MapSearch goToPlace={goToPlace}/> <MapSearch goToPlace={goToPlace} mapRef={mapRef} selectFeatureOnMap={selectFeatureOnMap}/>
{/* Geolocate Icon */} {/* Geolocate Icon */}
<div className={styles.geolocateBox}> <div className={styles.geolocateBox}>

View file

@ -4,6 +4,8 @@ import {LngLatBoundsLike} from 'maplibre-gl';
import {useIntl} from 'gatsby-plugin-intl'; import {useIntl} from 'gatsby-plugin-intl';
import {Search} from '@trussworks/react-uswds'; import {Search} from '@trussworks/react-uswds';
import {useWindowSize} from 'react-use'; import {useWindowSize} from 'react-use';
import {RefObject} from 'react';
import {MapRef} from 'react-map-gl';
import * as JsSearch from 'js-search'; import * as JsSearch from 'js-search';
import * as constants from '../../data/constants'; import * as constants from '../../data/constants';
@ -14,19 +16,17 @@ import * as EXPLORE_COPY from '../../data/copy/explore';
interface IMapSearch { interface IMapSearch {
goToPlace(bounds: LngLatBoundsLike):void; goToPlace(bounds: LngLatBoundsLike):void;
mapRef:RefObject<MapRef>;
selectFeatureOnMap: (feature: any) => void;
} }
interface ISearchResult { interface ISearchTractRecord {
addresstype: string; GEOID10: string;
lat: string; INTPTLAT10: string;
lon: string; INTPTLON10: string;
boundingbox: string[];
type: string;
// eslint-disable-next-line camelcase
place_rank: number;
} }
const MapSearch = ({goToPlace}:IMapSearch) => { const MapSearch = ({goToPlace, mapRef, selectFeatureOnMap}:IMapSearch) => {
// State to hold if the search results are empty or not: // State to hold if the search results are empty or not:
const [isSearchResultsNull, setIsSearchResultsNull] = useState(false); const [isSearchResultsNull, setIsSearchResultsNull] = useState(false);
const intl = useIntl(); const intl = useIntl();
@ -42,7 +42,7 @@ const MapSearch = ({goToPlace}:IMapSearch) => {
*/ */
const {width, height} = useWindowSize(); const {width, height} = useWindowSize();
const [placeholderText, setPlaceholderText]= useState(EXPLORE_COPY.MAP.SEARCH_PLACEHOLDER); const [placeholderText, setPlaceholderText]= useState(EXPLORE_COPY.MAP.SEARCH_PLACEHOLDER);
const [tractSearch, setTractSearch] = useState<JsSearch | null>(null); const [tractSearch, setTractSearch] = useState<JsSearch.Search | null>(null);
/** /**
* Gets the tract search data and loads in the state. * Gets the tract search data and loads in the state.
@ -80,61 +80,74 @@ const MapSearch = ({goToPlace}:IMapSearch) => {
/** /**
* Searchs for a given Census tract ID. * Searchs for a given Census tract ID.
* @param {string} tract the 11 digit tract ID as a string * @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. // We create a bounding box just to get the tract in the view box.
// The size is not important. // The size is not important.
const BOUNDING_BOX_SIZE_DD = 0.2; const BOUNDING_BOX_SIZE_DD = 0.1;
if (tractSearch) { if (tractSearch) {
// Convert 10 digit tracts to 11. // Convert 10 digit tracts to 11.
const searchTerm = tract.length == 10 ? '0' + tract : tract; const searchTerm = tract.length == 10 ? '0' + tract : tract;
const result = tractSearch.search(searchTerm); const result = tractSearch.search(searchTerm);
if (result.length > 0) { if (result.length > 0) {
const lat = Number(result[0].INTPTLAT10); const searchTractRecord = result[0] as ISearchTractRecord;
const lon = Number(result[0].INTPTLON10); const lat = Number(searchTractRecord.INTPTLAT10);
return [{ const lon = Number(searchTractRecord.INTPTLON10);
addresstype: 'tract', const boundingBox = [
boundingbox: [
(lat - (BOUNDING_BOX_SIZE_DD / 2)).toString(), (lat - (BOUNDING_BOX_SIZE_DD / 2)).toString(),
(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(),
(lon + (BOUNDING_BOX_SIZE_DD / 2)).toString(), (lon + (BOUNDING_BOX_SIZE_DD / 2)).toString(),
], ];
lat: result[0].INTPTLAT10, const [latMin, latMax, longMin, longMax] = boundingBox;
lon: result[0].INTPTLON10, setIsSearchResultsNull(false);
type: 'tract',
place_rank: 1, // 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 * Searchs for a given location such as address, zip, etc. This method will
1. extract the search term from the input field * will fetch data from the PSM API and return the results as JSON and
2. Determine if the search term is a Census Tract or not. * results to US only. If the data is valid, destructure the boundingBox
3. If it is a Census Tract, it will search the tract table for a bounding box. * values from the search results. Finally, is pans the map to the location.
4. If it is NOT a Census Tract, it will fetch data from the API and return the * @param {string} searchTerm the location to search for
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<HTMLFormElement>) => { const searchForLocation = async (searchTerm: string) => {
event.preventDefault(); const searchResults = await fetch(
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`, `https://nominatim.openstreetmap.org/search?q=${searchTerm}&format=json&countrycodes=us`,
{ {
mode: 'cors', mode: 'cors',
@ -149,7 +162,6 @@ const MapSearch = ({goToPlace}:IMapSearch) => {
console.error('There has been a problem with your fetch operation:', error); console.error('There has been a problem with your fetch operation:', error);
}); });
console.log('Nominatum search results: ', searchResults); console.log('Nominatum search results: ', searchResults);
}
// If results are valid, set isSearchResultsNull to false and pan map to location: // If results are valid, set isSearchResultsNull to false and pan map to location:
if (searchResults && searchResults.length > 0) { 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<HTMLFormElement>} event the click event
*/
const onSearchHandler = async (event: React.FormEvent<HTMLFormElement>) => {
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 ( return (
<div className={styles.mapSearchContainer}> <div className={styles.mapSearchContainer}>
<MapSearchMessage isSearchResultsNull={isSearchResultsNull} /> <MapSearchMessage isSearchResultsNull={isSearchResultsNull} />