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

View file

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

View file

@ -4,6 +4,7 @@ from pathlib import Path
import numpy as np
from numpy import float64
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 CSVConfig
@ -42,10 +43,12 @@ class PostScoreETL(ExtractTransformLoad):
self.input_counties_df: pd.DataFrame
self.input_states_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_tiles_df: pd.DataFrame
self.output_downloadable_df: pd.DataFrame
self.output_tract_search_df: pd.DataFrame
# Define some constants for the YAML file
# TODO: Implement this as a marshmallow schema.
@ -105,6 +108,18 @@ class PostScoreETL(ExtractTransformLoad):
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:
super().extract(
@ -131,6 +146,9 @@ class PostScoreETL(ExtractTransformLoad):
self.input_score_df = self._extract_score(
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(
self, initial_counties_df: pd.DataFrame
@ -392,7 +410,23 @@ class PostScoreETL(ExtractTransformLoad):
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:
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_states = self._transform_states(self.input_states_df)
transformed_score = self._transform_score(self.input_score_df)
@ -409,6 +443,9 @@ class PostScoreETL(ExtractTransformLoad):
self.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(
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)
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:
self._load_score_csv_full(
self.output_score_county_state_merged_df,
@ -600,4 +644,5 @@ class PostScoreETL(ExtractTransformLoad):
self._load_tile_csv(
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)

View file

@ -3,6 +3,7 @@ from importlib import reload
from pathlib import Path
import pandas as pd
import geopandas as gpd
import pytest
from data_pipeline import config
from data_pipeline.etl.score import etl_score_post
@ -144,3 +145,13 @@ def downloadable_data_expected():
return pd.read_pickle(
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.testing as pdt
import pandas as pd
import geopandas as gpd
from data_pipeline.content.schemas.download_schemas import CSVConfig
from data_pipeline.etl.score import constants
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
@ -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_CSV_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)