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",
"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",

View file

@ -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",

View file

@ -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 */}
<div className={styles.mapHeaderRow}>
<MapSearch goToPlace={goToPlace}/>
<MapSearch goToPlace={goToPlace} mapRef={mapRef} selectFeatureOnMap={selectFeatureOnMap}/>
{/* Geolocate Icon */}
<div className={styles.geolocateBox}>

View file

@ -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<MapRef>;
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<JsSearch | null>(null);
const [tractSearch, setTractSearch] = useState<JsSearch.Search | null>(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<HTMLFormElement>) => {
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<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 (
<div className={styles.mapSearchContainer}>
<MapSearchMessage isSearchResultsNull={isSearchResultsNull} />