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
parent 4130c46aee
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-env-variables": "^2.2.0",
"gatsby-plugin-robots-txt": "^1.7.0", "gatsby-plugin-robots-txt": "^1.7.0",
"gatsby-plugin-sitemap": "^4.10.0", "gatsby-plugin-sitemap": "^4.10.0",
"js-search": "^2.0.1",
"mapbox-gl": "^1.13.2", "mapbox-gl": "^1.13.2",
"maplibre-gl": "^1.14.0", "maplibre-gl": "^1.14.0",
"query-string": "^7.1.3", "query-string": "^7.1.3",

View file

@ -130,7 +130,7 @@ const J40Map = ({location}: IJ40Interface) => {
const onClick = (event: MapEvent | React.MouseEvent<HTMLButtonElement>) => { const onClick = (event: MapEvent | React.MouseEvent<HTMLButtonElement>) => {
// Stop all propagation / bubbling / capturing // Stop all propagation / bubbling / capturing
event.preventDefault(); 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 // 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 // guaranteed that each territory control will have an id. We use this ID to determine
@ -167,8 +167,9 @@ const J40Map = ({location}: IJ40Interface) => {
default: default:
break; break;
} }
} else { } else if (event.target && (event.target as HTMLElement).nodeName == 'DIV' ) {
// This else clause will fire when the ID is null or empty. This is the case where the map is clicked // 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 // @ts-ignore
const feature = event.features && event.features[0]; 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 {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 * as JsSearch from 'js-search';
import * as constants from '../../data/constants';
import MapSearchMessage from '../MapSearchMessage'; import MapSearchMessage from '../MapSearchMessage';
@ -14,6 +16,16 @@ interface IMapSearch {
goToPlace(bounds: LngLatBoundsLike):void; 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) => { const MapSearch = ({goToPlace}: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);
@ -30,24 +42,99 @@ 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);
/**
* 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( () => { useEffect( () => {
width > height ? setPlaceholderText(EXPLORE_COPY.MAP.SEARCH_PLACEHOLDER): setPlaceholderText(EXPLORE_COPY.MAP.SEARCH_PLACEHOLDER_MOBILE); width > height ? setPlaceholderText(EXPLORE_COPY.MAP.SEARCH_PLACEHOLDER): setPlaceholderText(EXPLORE_COPY.MAP.SEARCH_PLACEHOLDER_MOBILE);
}, [width]); }, [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 onSearchHandler will
1. extract the search term from the input field 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 2. Determine if the search term is a Census Tract or not.
3. if data is valid, destructure the boundingBox values from the search results 3. If it is a Census Tract, it will search the tract table for a bounding box.
4. pan the map to that location 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>) => { const onSearchHandler = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
const searchTerm = (event.currentTarget.elements.namedItem('search') as HTMLInputElement).value; const searchTerm = (event.currentTarget.elements.namedItem('search') as HTMLInputElement).value;
let searchResults = null;
const searchResults = await fetch( // 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',
@ -61,13 +148,12 @@ const MapSearch = ({goToPlace}:IMapSearch) => {
.catch((error) => { .catch((error) => {
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);
}
// 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) {
setIsSearchResultsNull(false); setIsSearchResultsNull(false);
console.log('Nominatum search results: ', searchResults);
const [latMin, latMax, longMin, longMax] = searchResults[0].boundingbox; const [latMin, latMax, longMin, longMax] = searchResults[0].boundingbox;
goToPlace([[Number(longMin), Number(latMin)], [Number(longMax), Number(latMax)]]); goToPlace([[Number(longMin), Number(latMin)], [Number(longMax), Number(latMax)]]);
} else { } else {

View file

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

View file

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

View file

@ -380,3 +380,13 @@ export const CENSUS_TRACT_SURVEY_LINKS = {
EN: "https://eop.gov1.qualtrics.com/jfe/form/SV_8J5wGa8Ya4dMP9c", EN: "https://eop.gov1.qualtrics.com/jfe/form/SV_8J5wGa8Ya4dMP9c",
ES: "https://eop.gov1.qualtrics.com/jfe/form/SV_eJXos5X4yekq6cC", 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: { SEARCH_PLACEHOLDER: {
id: 'explore.map.page.map.search.placeholder.text', 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', description: 'On the explore the map page, on the map, the placeholder text for search',
}, },
SEARCH_PLACEHOLDER_MOBILE: { SEARCH_PLACEHOLDER_MOBILE: {

View file

@ -688,7 +688,7 @@
"description": "On the explore the map page, on the map, the placeholder text for search" "description": "On the explore the map page, on the map, the placeholder text for search"
}, },
"explore.map.page.map.search.placeholder.text": { "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" "description": "On the explore the map page, on the map, the placeholder text for search"
}, },
"explore.map.page.map.search.results.empty.text": { "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.long": "Tierras tribales",
"explore.map.page.map.layer.selector.tribal.short": "Tribal", "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.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.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.long": "Alaska",
"explore.map.page.map.territoryFocus.alaska.short": "AK", "explore.map.page.map.territoryFocus.alaska.short": "AK",

View file

@ -24,6 +24,7 @@ DATA_CENSUS_DIR = DATA_PATH / "census"
DATA_CENSUS_CSV_DIR = DATA_CENSUS_DIR / "csv" DATA_CENSUS_CSV_DIR = DATA_CENSUS_DIR / "csv"
DATA_CENSUS_CSV_FILE_PATH = DATA_CENSUS_CSV_DIR / "us.csv" DATA_CENSUS_CSV_FILE_PATH = DATA_CENSUS_CSV_DIR / "us.csv"
DATA_CENSUS_CSV_STATE_FILE_PATH = DATA_CENSUS_CSV_DIR / "fips_states_2010.csv" DATA_CENSUS_CSV_STATE_FILE_PATH = DATA_CENSUS_CSV_DIR / "fips_states_2010.csv"
DATA_CENSUS_GEOJSON_FILE_PATH = DATA_CENSUS_DIR / "geojson" / "us.json"
# Score paths # Score paths
DATA_SCORE_DIR = DATA_PATH / "score" DATA_SCORE_DIR = DATA_PATH / "score"
@ -46,6 +47,9 @@ DATA_SCORE_JSON_INDEX_FILE_PATH = (
## Tile path ## Tile path
DATA_SCORE_TILES_DIR = DATA_SCORE_DIR / "tiles" DATA_SCORE_TILES_DIR = DATA_SCORE_DIR / "tiles"
## Tiles search
DATA_TILES_SEARCH_DIR = DATA_SCORE_DIR / "search"
# Downloadable paths # Downloadable paths
if not os.environ.get("J40_VERSION_LABEL_STRING"): if not os.environ.get("J40_VERSION_LABEL_STRING"):
version_str = "beta" version_str = "beta"
@ -82,6 +86,7 @@ SCORE_VERSIONING_README_FILE_NAME = f"readme-version-{version_str}.md"
SCORE_VERSIONING_README_FILE_PATH = ( SCORE_VERSIONING_README_FILE_PATH = (
FILES_PATH / SCORE_VERSIONING_README_FILE_NAME FILES_PATH / SCORE_VERSIONING_README_FILE_NAME
) )
SCORE_TRACT_SEARCH_FILE_PATH = DATA_TILES_SEARCH_DIR / "tracts.json"
# For the codebook # For the codebook
CEJST_SCORE_COLUMN_NAME = "score_name" CEJST_SCORE_COLUMN_NAME = "score_name"

View file

@ -4,6 +4,7 @@ from pathlib import Path
import numpy as np import numpy as np
from numpy import float64 from numpy import float64
import pandas as pd import pandas as pd
import geopandas as gpd
from data_pipeline.content.schemas.download_schemas import CodebookConfig from data_pipeline.content.schemas.download_schemas import CodebookConfig
from data_pipeline.content.schemas.download_schemas import CSVConfig from data_pipeline.content.schemas.download_schemas import CSVConfig
@ -42,10 +43,12 @@ class PostScoreETL(ExtractTransformLoad):
self.input_counties_df: pd.DataFrame self.input_counties_df: pd.DataFrame
self.input_states_df: pd.DataFrame self.input_states_df: pd.DataFrame
self.input_score_df: pd.DataFrame self.input_score_df: pd.DataFrame
self.input_census_geo_df: gpd.GeoDataFrame
self.output_score_county_state_merged_df: pd.DataFrame self.output_score_county_state_merged_df: pd.DataFrame
self.output_score_tiles_df: pd.DataFrame self.output_score_tiles_df: pd.DataFrame
self.output_downloadable_df: pd.DataFrame self.output_downloadable_df: pd.DataFrame
self.output_tract_search_df: pd.DataFrame
# Define some constants for the YAML file # Define some constants for the YAML file
# TODO: Implement this as a marshmallow schema. # TODO: Implement this as a marshmallow schema.
@ -105,6 +108,18 @@ class PostScoreETL(ExtractTransformLoad):
return df return df
def _extract_census_geojson(self, geo_path: Path) -> gpd.GeoDataFrame:
"""
Read in the Census Geo JSON data.
Returns:
gpd.GeoDataFrame: the census geo json data
"""
logger.debug("Reading Census GeoJSON")
with open(geo_path, "r", encoding="utf-8") as file:
data = gpd.read_file(file)
return data
def extract(self, use_cached_data_sources: bool = False) -> None: def extract(self, use_cached_data_sources: bool = False) -> None:
super().extract( super().extract(
@ -131,6 +146,9 @@ class PostScoreETL(ExtractTransformLoad):
self.input_score_df = self._extract_score( self.input_score_df = self._extract_score(
constants.DATA_SCORE_CSV_FULL_FILE_PATH constants.DATA_SCORE_CSV_FULL_FILE_PATH
) )
self.input_census_geo_df = self._extract_census_geojson(
constants.DATA_CENSUS_GEOJSON_FILE_PATH
)
def _transform_counties( def _transform_counties(
self, initial_counties_df: pd.DataFrame self, initial_counties_df: pd.DataFrame
@ -392,7 +410,23 @@ class PostScoreETL(ExtractTransformLoad):
return final_df return final_df
def _create_tract_search_data(
self, census_geojson: gpd.GeoDataFrame
) -> pd.DataFrame:
"""
Generate a dataframe with only the tract IDs and the center lat/lon of each tract.
Returns:
pd.DataFrame: a dataframe with the tract search data
"""
logger.debug("Creating Census tract search data")
columns_to_extract = ["GEOID10", "INTPTLAT10", "INTPTLON10"]
return pd.DataFrame(census_geojson[columns_to_extract])
def transform(self) -> None: def transform(self) -> None:
self.output_tract_search_df = self._create_tract_search_data(
self.input_census_geo_df
)
transformed_counties = self._transform_counties(self.input_counties_df) transformed_counties = self._transform_counties(self.input_counties_df)
transformed_states = self._transform_states(self.input_states_df) transformed_states = self._transform_states(self.input_states_df)
transformed_score = self._transform_score(self.input_score_df) transformed_score = self._transform_score(self.input_score_df)
@ -409,6 +443,9 @@ class PostScoreETL(ExtractTransformLoad):
self.output_score_county_state_merged_df = ( self.output_score_county_state_merged_df = (
output_score_county_state_merged_df output_score_county_state_merged_df
) )
self.output_tract_search_df = self._create_tract_search_data(
self.input_census_geo_df
)
def _load_score_csv_full( def _load_score_csv_full(
self, score_county_state_merged: pd.DataFrame, score_csv_path: Path self, score_county_state_merged: pd.DataFrame, score_csv_path: Path
@ -592,6 +629,13 @@ class PostScoreETL(ExtractTransformLoad):
] ]
zip_files(version_data_documentation_zip_path, files_to_compress) zip_files(version_data_documentation_zip_path, files_to_compress)
def _load_search_tract_data(self, output_path: Path):
"""Write the Census tract search data."""
logger.debug("Writing Census tract search data")
output_path.parent.mkdir(parents=True, exist_ok=True)
# We use the records orientation to easily import the JSON in JS.
self.output_tract_search_df.to_json(output_path, orient="records")
def load(self) -> None: def load(self) -> None:
self._load_score_csv_full( self._load_score_csv_full(
self.output_score_county_state_merged_df, self.output_score_county_state_merged_df,
@ -600,4 +644,5 @@ class PostScoreETL(ExtractTransformLoad):
self._load_tile_csv( self._load_tile_csv(
self.output_score_tiles_df, constants.DATA_SCORE_CSV_TILES_FILE_PATH self.output_score_tiles_df, constants.DATA_SCORE_CSV_TILES_FILE_PATH
) )
self._load_search_tract_data(constants.SCORE_TRACT_SEARCH_FILE_PATH)
self._load_downloadable_zip(constants.SCORE_DOWNLOADABLE_DIR) self._load_downloadable_zip(constants.SCORE_DOWNLOADABLE_DIR)

View file

@ -3,6 +3,7 @@ from importlib import reload
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
import geopandas as gpd
import pytest import pytest
from data_pipeline import config from data_pipeline import config
from data_pipeline.etl.score import etl_score_post from data_pipeline.etl.score import etl_score_post
@ -144,3 +145,13 @@ def downloadable_data_expected():
return pd.read_pickle( return pd.read_pickle(
pytest.SNAPSHOT_DIR / "downloadable_data_expected.pkl" pytest.SNAPSHOT_DIR / "downloadable_data_expected.pkl"
) )
@pytest.fixture()
def census_geojson_sample_data(sample_data_dir) -> gpd.GeoDataFrame:
with open(
sample_data_dir / "census_60.geojson", "r", encoding="utf-8"
) as file:
data = gpd.read_file(file)
return data
return None

File diff suppressed because one or more lines are too long

View file

@ -5,9 +5,12 @@ from pathlib import Path
import pandas.api.types as ptypes import pandas.api.types as ptypes
import pandas.testing as pdt import pandas.testing as pdt
import pandas as pd
import geopandas as gpd
from data_pipeline.content.schemas.download_schemas import CSVConfig from data_pipeline.content.schemas.download_schemas import CSVConfig
from data_pipeline.etl.score import constants from data_pipeline.etl.score import constants
from data_pipeline.utils import load_yaml_dict_from_file from data_pipeline.utils import load_yaml_dict_from_file
from data_pipeline.etl.score.etl_score_post import PostScoreETL
# See conftest.py for all fixtures used in these tests # See conftest.py for all fixtures used in these tests
@ -150,3 +153,16 @@ def test_load_downloadable_zip(etl, monkeypatch, score_data_expected):
assert constants.SCORE_DOWNLOADABLE_EXCEL_FILE_PATH.is_file() assert constants.SCORE_DOWNLOADABLE_EXCEL_FILE_PATH.is_file()
assert constants.SCORE_DOWNLOADABLE_CSV_ZIP_FILE_PATH.is_file() assert constants.SCORE_DOWNLOADABLE_CSV_ZIP_FILE_PATH.is_file()
assert constants.SCORE_DOWNLOADABLE_XLS_ZIP_FILE_PATH.is_file() assert constants.SCORE_DOWNLOADABLE_XLS_ZIP_FILE_PATH.is_file()
def test_create_tract_search_data(census_geojson_sample_data: gpd.GeoDataFrame):
# Sanity check
assert len(census_geojson_sample_data) > 0
result = PostScoreETL()._create_tract_search_data(census_geojson_sample_data)
assert isinstance(result, pd.DataFrame)
assert not result.columns.empty
columns = ["GEOID10", "INTPTLAT10", "INTPTLON10"]
for col in columns:
assert col in result.columns
assert len(census_geojson_sample_data) == len(result)