mirror of
https://github.com/DOI-DO/j40-cejst-2.git
synced 2025-02-22 01:31:25 -08:00
Allow for Census Tract search in UI
This commit is contained in:
parent
4130c46aee
commit
cf4e35acce
15 changed files with 362 additions and 162 deletions
261
client/package-lock.json
generated
261
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
@ -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)
|
||||
|
|
Loading…
Add table
Reference in a new issue