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,76 +80,88 @@ 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(), ];
], const [latMin, latMax, longMin, longMax] = boundingBox;
lat: result[0].INTPTLAT10, setIsSearchResultsNull(false);
lon: result[0].INTPTLON10,
type: 'tract', // Now move the map and select the tract.
place_rank: 1, 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. const searchForLocation = async (searchTerm: string) => {
4. Pan the map to that location const searchResults = await fetch(
*/ `https://nominatim.openstreetmap.org/search?q=${searchTerm}&format=json&countrycodes=us`,
const onSearchHandler = async (event: React.FormEvent<HTMLFormElement>) => { {
event.preventDefault(); mode: 'cors',
event.stopPropagation(); })
.then((response) => {
const searchTerm = (event.currentTarget.elements.namedItem('search') as HTMLInputElement).value; if (!response.ok) {
let searchResults = null; throw new Error('Network response was not OK');
}
// If the search term a Census tract return response.json();
const isTract = /^\d{10,11}$/.test(searchTerm); })
if (isTract) { .catch((error) => {
setIsSearchResultsNull(false); console.error('There has been a problem with your fetch operation:', error);
searchResults = searchForTract(searchTerm); });
} else { console.log('Nominatum search results: ', searchResults);
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 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} />