mirror of
https://github.com/DOI-DO/j40-cejst-2.git
synced 2025-08-25 07:31:40 -07:00
Search selects a tract on the map when searching for tract ID
This commit is contained in:
parent
e7be2b9236
commit
b6e906f639
4 changed files with 167 additions and 130 deletions
6
client/package-lock.json
generated
6
client/package-lock.json
generated
|
@ -3203,6 +3203,12 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz",
|
||||||
"integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA=="
|
"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": {
|
"@types/json-patch": {
|
||||||
"version": "0.0.30",
|
"version": "0.0.30",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-patch/-/json-patch-0.0.30.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-patch/-/json-patch-0.0.30.tgz",
|
||||||
|
|
|
@ -39,6 +39,7 @@
|
||||||
"@testing-library/react": "^11.2.7",
|
"@testing-library/react": "^11.2.7",
|
||||||
"@types/d3-ease": "^3.0.0",
|
"@types/d3-ease": "^3.0.0",
|
||||||
"@types/jest": "^26.0.24",
|
"@types/jest": "^26.0.24",
|
||||||
|
"@types/js-search": "^1.4.4",
|
||||||
"@types/maplibre-gl": "^1.14.0",
|
"@types/maplibre-gl": "^1.14.0",
|
||||||
"@types/node": "^15.14.9",
|
"@types/node": "^15.14.9",
|
||||||
"@types/react": "^17.0.41",
|
"@types/react": "^17.0.41",
|
||||||
|
|
|
@ -57,6 +57,13 @@ export interface IDetailViewInterface {
|
||||||
properties: constants.J40Properties,
|
properties: constants.J40Properties,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface IMapFeature {
|
||||||
|
id: string;
|
||||||
|
geometry: any;
|
||||||
|
properties: any;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
const J40Map = ({location}: IJ40Interface) => {
|
const J40Map = ({location}: IJ40Interface) => {
|
||||||
/**
|
/**
|
||||||
* Initializes the zoom, and the map's center point (lat, lng) via the URL hash #{z}/{lat}/{long}
|
* 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();
|
const zoomLatLngHash = mapRef.current?.getMap()._hash._getCurrentHash();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function will return the bounding box of the current map. Comment in when needed.
|
* Selects the provided feature on the map.
|
||||||
* {
|
* @param feature the feature to select
|
||||||
* _ne: {lng:number, lat:number}
|
|
||||||
* _sw: {lng:number, lat:number}
|
|
||||||
* }
|
|
||||||
* @returns {LngLatBounds}
|
|
||||||
*/
|
*/
|
||||||
// const getCurrentMapBoundingBox = () => {
|
const selectFeatureOnMap = (feature: IMapFeature) => {
|
||||||
// return mapRef.current ? console.log('mapRef getBounds(): ', mapRef.current.getMap().getBounds()) : null;
|
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
|
* 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
|
// @ts-ignore
|
||||||
const feature = event.features && event.features[0];
|
const feature = event.features && event.features[0];
|
||||||
|
|
||||||
if (feature) {
|
selectFeatureOnMap(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
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -390,7 +389,7 @@ const J40Map = ({location}: IJ40Interface) => {
|
||||||
|
|
||||||
{/* This is the first overlayed row on the map: Search and Geolocation */}
|
{/* This is the first overlayed row on the map: Search and Geolocation */}
|
||||||
<div className={styles.mapHeaderRow}>
|
<div className={styles.mapHeaderRow}>
|
||||||
<MapSearch goToPlace={goToPlace}/>
|
<MapSearch goToPlace={goToPlace} mapRef={mapRef} selectFeatureOnMap={selectFeatureOnMap}/>
|
||||||
|
|
||||||
{/* Geolocate Icon */}
|
{/* Geolocate Icon */}
|
||||||
<div className={styles.geolocateBox}>
|
<div className={styles.geolocateBox}>
|
||||||
|
|
|
@ -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 {RefObject} from 'react';
|
||||||
|
import {MapRef} from 'react-map-gl';
|
||||||
import * as JsSearch from 'js-search';
|
import * as JsSearch from 'js-search';
|
||||||
import * as constants from '../../data/constants';
|
import * as constants from '../../data/constants';
|
||||||
|
|
||||||
|
@ -14,19 +16,17 @@ import * as EXPLORE_COPY from '../../data/copy/explore';
|
||||||
|
|
||||||
interface IMapSearch {
|
interface IMapSearch {
|
||||||
goToPlace(bounds: LngLatBoundsLike):void;
|
goToPlace(bounds: LngLatBoundsLike):void;
|
||||||
|
mapRef:RefObject<MapRef>;
|
||||||
|
selectFeatureOnMap: (feature: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ISearchResult {
|
interface ISearchTractRecord {
|
||||||
addresstype: string;
|
GEOID10: string;
|
||||||
lat: string;
|
INTPTLAT10: string;
|
||||||
lon: string;
|
INTPTLON10: string;
|
||||||
boundingbox: string[];
|
|
||||||
type: string;
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
place_rank: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MapSearch = ({goToPlace}:IMapSearch) => {
|
const MapSearch = ({goToPlace, mapRef, selectFeatureOnMap}: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);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
@ -42,7 +42,7 @@ 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);
|
const [tractSearch, setTractSearch] = useState<JsSearch.Search | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the tract search data and loads in the state.
|
* 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.
|
* Searchs for a given Census tract ID.
|
||||||
* @param {string} tract the 11 digit tract ID as a string
|
* @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.
|
// We create a bounding box just to get the tract in the view box.
|
||||||
// The size is not important.
|
// The size is not important.
|
||||||
const BOUNDING_BOX_SIZE_DD = 0.2;
|
const BOUNDING_BOX_SIZE_DD = 0.1;
|
||||||
if (tractSearch) {
|
if (tractSearch) {
|
||||||
// Convert 10 digit tracts to 11.
|
// Convert 10 digit tracts to 11.
|
||||||
const searchTerm = tract.length == 10 ? '0' + tract : tract;
|
const searchTerm = tract.length == 10 ? '0' + tract : tract;
|
||||||
const result = tractSearch.search(searchTerm);
|
const result = tractSearch.search(searchTerm);
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
const lat = Number(result[0].INTPTLAT10);
|
const searchTractRecord = result[0] as ISearchTractRecord;
|
||||||
const lon = Number(result[0].INTPTLON10);
|
const lat = Number(searchTractRecord.INTPTLAT10);
|
||||||
return [{
|
const lon = Number(searchTractRecord.INTPTLON10);
|
||||||
addresstype: 'tract',
|
const boundingBox = [
|
||||||
boundingbox: [
|
(lat - (BOUNDING_BOX_SIZE_DD / 2)).toString(),
|
||||||
(lat - (BOUNDING_BOX_SIZE_DD / 2)).toString(),
|
(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(),
|
(lon + (BOUNDING_BOX_SIZE_DD / 2)).toString(),
|
||||||
(lon + (BOUNDING_BOX_SIZE_DD / 2)).toString(),
|
];
|
||||||
],
|
const [latMin, latMax, longMin, longMax] = boundingBox;
|
||||||
lat: result[0].INTPTLAT10,
|
setIsSearchResultsNull(false);
|
||||||
lon: result[0].INTPTLON10,
|
|
||||||
type: 'tract',
|
// Now move the map and select the tract.
|
||||||
place_rank: 1,
|
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
|
* Searchs for a given location such as address, zip, etc. This method will
|
||||||
1. extract the search term from the input field
|
* will fetch data from the PSM API and return the results as JSON and
|
||||||
2. Determine if the search term is a Census Tract or not.
|
* results to US only. If the data is valid, destructure the boundingBox
|
||||||
3. If it is a Census Tract, it will search the tract table for a bounding box.
|
* values from the search results. Finally, is pans the map to the location.
|
||||||
4. If it is NOT a Census Tract, it will fetch data from the API and return the
|
* @param {string} searchTerm the location to search for
|
||||||
results as JSON and results to US only. If data is valid, destructure the
|
*/
|
||||||
boundingBox values from the search results.
|
const searchForLocation = async (searchTerm: string) => {
|
||||||
4. Pan the map to that location
|
const searchResults = await fetch(
|
||||||
*/
|
`https://nominatim.openstreetmap.org/search?q=${searchTerm}&format=json&countrycodes=us`,
|
||||||
const onSearchHandler = async (event: React.FormEvent<HTMLFormElement>) => {
|
{
|
||||||
event.preventDefault();
|
mode: 'cors',
|
||||||
event.stopPropagation();
|
})
|
||||||
|
.then((response) => {
|
||||||
const searchTerm = (event.currentTarget.elements.namedItem('search') as HTMLInputElement).value;
|
if (!response.ok) {
|
||||||
let searchResults = null;
|
throw new Error('Network response was not OK');
|
||||||
|
}
|
||||||
// If the search term a Census tract
|
return response.json();
|
||||||
const isTract = /^\d{10,11}$/.test(searchTerm);
|
})
|
||||||
if (isTract) {
|
.catch((error) => {
|
||||||
setIsSearchResultsNull(false);
|
console.error('There has been a problem with your fetch operation:', error);
|
||||||
searchResults = searchForTract(searchTerm);
|
});
|
||||||
} else {
|
console.log('Nominatum search results: ', searchResults);
|
||||||
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 results are valid, set isSearchResultsNull to false and pan map to location:
|
||||||
if (searchResults && searchResults.length > 0) {
|
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 (
|
return (
|
||||||
<div className={styles.mapSearchContainer}>
|
<div className={styles.mapSearchContainer}>
|
||||||
<MapSearchMessage isSearchResultsNull={isSearchResultsNull} />
|
<MapSearchMessage isSearchResultsNull={isSearchResultsNull} />
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue