mirror of
https://github.com/DOI-DO/j40-cejst-2.git
synced 2025-08-24 16:01: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",
|
||||
"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": {
|
||||
"version": "0.0.30",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-patch/-/json-patch-0.0.30.tgz",
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
"@testing-library/react": "^11.2.7",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/js-search": "^1.4.4",
|
||||
"@types/maplibre-gl": "^1.14.0",
|
||||
"@types/node": "^15.14.9",
|
||||
"@types/react": "^17.0.41",
|
||||
|
|
|
@ -57,6 +57,13 @@ export interface IDetailViewInterface {
|
|||
properties: constants.J40Properties,
|
||||
};
|
||||
|
||||
export interface IMapFeature {
|
||||
id: string;
|
||||
geometry: any;
|
||||
properties: any;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const J40Map = ({location}: IJ40Interface) => {
|
||||
/**
|
||||
* 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();
|
||||
|
||||
/**
|
||||
* This function will return the bounding box of the current map. Comment in when needed.
|
||||
* {
|
||||
* _ne: {lng:number, lat:number}
|
||||
* _sw: {lng:number, lat:number}
|
||||
* }
|
||||
* @returns {LngLatBounds}
|
||||
* Selects the provided feature on the map.
|
||||
* @param feature the feature to select
|
||||
*/
|
||||
// const getCurrentMapBoundingBox = () => {
|
||||
// return mapRef.current ? console.log('mapRef getBounds(): ', mapRef.current.getMap().getBounds()) : null;
|
||||
// };
|
||||
const selectFeatureOnMap = (feature: IMapFeature) => {
|
||||
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
|
||||
|
@ -174,58 +224,7 @@ const J40Map = ({location}: IJ40Interface) => {
|
|||
// @ts-ignore
|
||||
const feature = event.features && event.features[0];
|
||||
|
||||
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
|
||||
*/
|
||||
}
|
||||
selectFeatureOnMap(feature);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -390,7 +389,7 @@ const J40Map = ({location}: IJ40Interface) => {
|
|||
|
||||
{/* This is the first overlayed row on the map: Search and Geolocation */}
|
||||
<div className={styles.mapHeaderRow}>
|
||||
<MapSearch goToPlace={goToPlace}/>
|
||||
<MapSearch goToPlace={goToPlace} mapRef={mapRef} selectFeatureOnMap={selectFeatureOnMap}/>
|
||||
|
||||
{/* Geolocate Icon */}
|
||||
<div className={styles.geolocateBox}>
|
||||
|
|
|
@ -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 {RefObject} from 'react';
|
||||
import {MapRef} from 'react-map-gl';
|
||||
import * as JsSearch from 'js-search';
|
||||
import * as constants from '../../data/constants';
|
||||
|
||||
|
@ -14,19 +16,17 @@ import * as EXPLORE_COPY from '../../data/copy/explore';
|
|||
|
||||
interface IMapSearch {
|
||||
goToPlace(bounds: LngLatBoundsLike):void;
|
||||
mapRef:RefObject<MapRef>;
|
||||
selectFeatureOnMap: (feature: any) => void;
|
||||
}
|
||||
|
||||
interface ISearchResult {
|
||||
addresstype: string;
|
||||
lat: string;
|
||||
lon: string;
|
||||
boundingbox: string[];
|
||||
type: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
place_rank: number;
|
||||
interface ISearchTractRecord {
|
||||
GEOID10: string;
|
||||
INTPTLAT10: string;
|
||||
INTPTLON10: string;
|
||||
}
|
||||
|
||||
const MapSearch = ({goToPlace}:IMapSearch) => {
|
||||
const MapSearch = ({goToPlace, mapRef, selectFeatureOnMap}:IMapSearch) => {
|
||||
// State to hold if the search results are empty or not:
|
||||
const [isSearchResultsNull, setIsSearchResultsNull] = useState(false);
|
||||
const intl = useIntl();
|
||||
|
@ -42,7 +42,7 @@ const MapSearch = ({goToPlace}:IMapSearch) => {
|
|||
*/
|
||||
const {width, height} = useWindowSize();
|
||||
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.
|
||||
|
@ -80,76 +80,88 @@ const MapSearch = ({goToPlace}:IMapSearch) => {
|
|||
/**
|
||||
* 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] | [] => {
|
||||
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.
|
||||
// The size is not important.
|
||||
const BOUNDING_BOX_SIZE_DD = 0.2;
|
||||
const BOUNDING_BOX_SIZE_DD = 0.1;
|
||||
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,
|
||||
}];
|
||||
const searchTractRecord = result[0] as ISearchTractRecord;
|
||||
const lat = Number(searchTractRecord.INTPTLAT10);
|
||||
const lon = Number(searchTractRecord.INTPTLON10);
|
||||
const 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(),
|
||||
];
|
||||
const [latMin, latMax, longMin, longMax] = boundingBox;
|
||||
setIsSearchResultsNull(false);
|
||||
|
||||
// Now move the map and select the tract.
|
||||
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
|
||||
1. extract the search term from the input field
|
||||
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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
/**
|
||||
* Searchs for a given location such as address, zip, etc. This method will
|
||||
* will fetch data from the PSM API and return the results as JSON and
|
||||
* results to US only. If the data is valid, destructure the boundingBox
|
||||
* values from the search results. Finally, is pans the map to the location.
|
||||
* @param {string} searchTerm the location to search for
|
||||
*/
|
||||
const searchForLocation = async (searchTerm: string) => {
|
||||
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);
|
||||
});
|
||||
console.log('Nominatum search results: ', searchResults);
|
||||
|
||||
// If results are valid, set isSearchResultsNull to false and pan map to location:
|
||||
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 (
|
||||
<div className={styles.mapSearchContainer}>
|
||||
<MapSearchMessage isSearchResultsNull={isSearchResultsNull} />
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue