Allow for Census Tract search in UI

This commit is contained in:
Carlos Felix 2024-12-04 14:36:46 -05:00 committed by Carlos Felix
commit cf4e35acce
15 changed files with 362 additions and 162 deletions

261
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -85,6 +85,7 @@
"gatsby-plugin-env-variables": "^2.2.0",
"gatsby-plugin-robots-txt": "^1.7.0",
"gatsby-plugin-sitemap": "^4.10.0",
"js-search": "^2.0.1",
"mapbox-gl": "^1.13.2",
"maplibre-gl": "^1.14.0",
"query-string": "^7.1.3",

View file

@ -130,7 +130,7 @@ const J40Map = ({location}: IJ40Interface) => {
const onClick = (event: MapEvent | React.MouseEvent<HTMLButtonElement>) => {
// Stop all propagation / bubbling / capturing
event.preventDefault();
event.stopPropagation();
(event as React.MouseEvent<HTMLButtonElement>).stopPropagation?.();
// Check if the click is for territories. Given the territories component's design, it can be
// guaranteed that each territory control will have an id. We use this ID to determine
@ -167,8 +167,9 @@ const J40Map = ({location}: IJ40Interface) => {
default:
break;
}
} else {
// This else clause will fire when the ID is null or empty. This is the case where the map is clicked
} else if (event.target && (event.target as HTMLElement).nodeName == 'DIV' ) {
// This else clause will fire when the user clicks on the map and will ignore other controls
// such as the search box and buttons.
// @ts-ignore
const feature = event.features && event.features[0];

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 * as JsSearch from 'js-search';
import * as constants from '../../data/constants';
import MapSearchMessage from '../MapSearchMessage';
@ -14,6 +16,16 @@ interface IMapSearch {
goToPlace(bounds: LngLatBoundsLike):void;
}
interface ISearchResult {
addresstype: string;
lat: string;
lon: string;
boundingbox: string[];
type: string;
// eslint-disable-next-line camelcase
place_rank: number;
}
const MapSearch = ({goToPlace}:IMapSearch) => {
// State to hold if the search results are empty or not:
const [isSearchResultsNull, setIsSearchResultsNull] = useState(false);
@ -30,44 +42,118 @@ const MapSearch = ({goToPlace}:IMapSearch) => {
*/
const {width, height} = useWindowSize();
const [placeholderText, setPlaceholderText]= useState(EXPLORE_COPY.MAP.SEARCH_PLACEHOLDER);
const [tractSearch, setTractSearch] = useState<JsSearch | null>(null);
/**
* Gets the tract search data and loads in the state.
*/
const getTractSearchData = async () => {
const searchDataUrl = `${constants.TILE_BASE_URL}/${constants.MAP_TRACT_SEARCH_PATH}`;
fetch(searchDataUrl)
.then((response) => {
if (response.ok) {
return response.json();
} else {
throw new Error(`${response.statusText} error with status code of ${response.status}`);
}
})
.then((data) => {
// We use JsSearch to make it easy to load and quick to search.
const search = new JsSearch.Search('GEOID10');
search.indexStrategy = new JsSearch.ExactWordIndexStrategy();
search.addIndex('GEOID10');
search.addDocuments(data);
setTractSearch(search);
})
.catch((error) =>
console.error('Unable to read search tract table:', error));
};
useEffect( () => {
width > height ? setPlaceholderText(EXPLORE_COPY.MAP.SEARCH_PLACEHOLDER): setPlaceholderText(EXPLORE_COPY.MAP.SEARCH_PLACEHOLDER_MOBILE);
}, [width]);
useEffect(()=>{
getTractSearchData();
}, []);
/**
* 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] | [] => {
// 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;
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,
}];
}
}
return [];
};
/*
onSearchHandler will
1. extract the search term from the input field
2. fetch data from the API and return the results as JSON and results to US only
3. if data is valid, destructure the boundingBox values from the search results
4. pan the map to that location
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;
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);
});
// 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);
}
// If results are valid, set isSearchResultsNull to false and pan map to location:
if (searchResults && searchResults.length > 0) {
setIsSearchResultsNull(false);
console.log('Nominatum search results: ', searchResults);
const [latMin, latMax, longMin, longMax] = searchResults[0].boundingbox;
goToPlace([[Number(longMin), Number(latMin)], [Number(longMax), Number(latMax)]]);
} else {

View file

@ -23,7 +23,7 @@ exports[`rendering of the MapSearch checks if component renders 1`] = `
data-testid="textInput"
id="search-field"
name="search"
placeholder="Search for an address, city, state or ZIP"
placeholder="Search for an address, city, state, ZIP or Census Tract"
type="search"
/>
<button

View file

@ -38,13 +38,8 @@ export const featureURLForTilesetName = (tilesetName: string): string => {
} else {
// The feature tile base URL and path can either point locally or the CDN.
// This is selected based on the DATA_SOURCE env variable.
const featureTileBaseURL = process.env.DATA_SOURCE === 'local' ?
process.env.GATSBY_LOCAL_TILES_BASE_URL :
process.env.GATSBY_CDN_TILES_BASE_URL;
const featureTilePath = process.env.DATA_SOURCE === 'local' ?
process.env.GATSBY_DATA_PIPELINE_SCORE_PATH_LOCAL :
process.env.GATSBY_2_0_SCORE_PATH;
const featureTileBaseURL = constants.TILE_BASE_URL;
const featureTilePath = constants.TILE_PATH;
return [
featureTileBaseURL,

View file

@ -380,3 +380,13 @@ export const CENSUS_TRACT_SURVEY_LINKS = {
EN: "https://eop.gov1.qualtrics.com/jfe/form/SV_8J5wGa8Ya4dMP9c",
ES: "https://eop.gov1.qualtrics.com/jfe/form/SV_eJXos5X4yekq6cC",
};
export const TILE_BASE_URL = process.env.DATA_SOURCE === "local" ?
process.env.GATSBY_LOCAL_TILES_BASE_URL :
process.env.GATSBY_CDN_TILES_BASE_URL;
export const TILE_PATH = process.env.DATA_SOURCE === "local" ?
process.env.GATSBY_DATA_PIPELINE_SCORE_PATH_LOCAL :
process.env.GATSBY_1_0_SCORE_PATH;
export const MAP_TRACT_SEARCH_PATH = "data_pipeline/data/score/search/tracts.json";

View file

@ -92,7 +92,7 @@ export const MAP = defineMessages({
},
SEARCH_PLACEHOLDER: {
id: 'explore.map.page.map.search.placeholder.text',
defaultMessage: 'Search for an address, city, state or ZIP',
defaultMessage: 'Search for an address, city, state, ZIP or Census Tract',
description: 'On the explore the map page, on the map, the placeholder text for search',
},
SEARCH_PLACEHOLDER_MOBILE: {

View file

@ -688,7 +688,7 @@
"description": "On the explore the map page, on the map, the placeholder text for search"
},
"explore.map.page.map.search.placeholder.text": {
"defaultMessage": "Search for an address, city, state or ZIP",
"defaultMessage": "Search for an address, city, state, ZIP or Census Tract",
"description": "On the explore the map page, on the map, the placeholder text for search"
},
"explore.map.page.map.search.results.empty.text": {

View file

@ -172,7 +172,7 @@
"explore.map.page.map.layer.selector.tribal.long": "Tierras tribales",
"explore.map.page.map.layer.selector.tribal.short": "Tribal",
"explore.map.page.map.search.placeholder.mobile.text": "Búsqueda de ubicaciones ",
"explore.map.page.map.search.placeholder.text": "Busque una dirección, ciudad, estado o código postal.",
"explore.map.page.map.search.placeholder.text": "Busque una dirección, ciudad, estado, código postal o distrito censal.",
"explore.map.page.map.search.results.empty.text": "No se encontró la ubicación o ubicación desconocida. Intente una búsqueda distinta.",
"explore.map.page.map.territoryFocus.alaska.long": "Alaska",
"explore.map.page.map.territoryFocus.alaska.short": "AK",