mirror of
https://github.com/DOI-DO/j40-cejst-2.git
synced 2025-09-30 03:23:17 -07:00
581 lines
21 KiB
TypeScript
581 lines
21 KiB
TypeScript
/* eslint-disable valid-jsdoc */
|
|
/* eslint-disable no-unused-vars */
|
|
// External Libs:
|
|
import React, {useRef, useState} from 'react';
|
|
import {Map, MapGeoJSONFeature, LngLatBoundsLike} from 'maplibre-gl';
|
|
import ReactMapGL, {
|
|
MapEvent,
|
|
ViewportProps,
|
|
WebMercatorViewport,
|
|
NavigationControl,
|
|
GeolocateControl,
|
|
Popup,
|
|
FlyToInterpolator,
|
|
FullscreenControl,
|
|
MapRef,
|
|
} from 'react-map-gl';
|
|
import {useIntl} from 'gatsby-plugin-intl';
|
|
import bbox from '@turf/bbox';
|
|
import * as d3 from 'd3-ease';
|
|
import {isMobile} from 'react-device-detect';
|
|
import {Grid} from '@trussworks/react-uswds';
|
|
import {useWindowSize, useLocalStorage} from 'react-use';
|
|
|
|
// Contexts:
|
|
import {useFlags} from '../contexts/FlagContext';
|
|
|
|
// Components:
|
|
import AreaDetail from './AreaDetail';
|
|
import MapInfoPanel from './mapInfoPanel';
|
|
import MapSearch from './MapSearch';
|
|
import MapTractLayers from './MapTractLayers/MapTractLayers';
|
|
import MapTribalLayer from './MapTribalLayers/MapTribalLayers';
|
|
import TerritoryFocusControl from './territoryFocusControl';
|
|
|
|
// Styles and constants
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
import * as constants from '../data/constants';
|
|
import * as styles from './J40Map.module.scss';
|
|
import * as EXPLORE_COPY from '../data/copy/explore';
|
|
import CreateReportPanel from './CreateReportPanel';
|
|
|
|
declare global {
|
|
interface Window {
|
|
Cypress?: object;
|
|
underlyingMap: Map;
|
|
}
|
|
}
|
|
|
|
interface IJ40Interface {
|
|
location: Location;
|
|
};
|
|
|
|
|
|
export interface IDetailViewInterface {
|
|
latitude: number
|
|
longitude: number
|
|
zoom: number
|
|
properties: constants.J40Properties,
|
|
};
|
|
|
|
export interface IMapFeature {
|
|
id: string;
|
|
geometry: any;
|
|
properties: any;
|
|
type: string;
|
|
}
|
|
|
|
const MAX_SELECTED_TRACTS = 20;
|
|
|
|
const J40Map = ({location}: IJ40Interface) => {
|
|
/**
|
|
* Initializes the zoom, and the map's center point (lat, lng) via the URL hash #{z}/{lat}/{long}
|
|
* where
|
|
* @TODO: These values do not update when zooming in/out. Could explain a number of cypress bugs
|
|
* reference to ticket #1550
|
|
*
|
|
* z = zoom
|
|
* lat = map center's latitude
|
|
* long = map center's longitude
|
|
*/
|
|
const [zoom, lat, lng] = location.hash.slice(1).split('/');
|
|
|
|
/**
|
|
* If the URL has no #{z}/{lat}/{long} specified in the hash, then set the map's intial viewport state
|
|
* to use constants. This is so that we can load URLs with certain zoom/lat/long specified:
|
|
*/
|
|
const [viewport, setViewport] = useState<ViewportProps>({
|
|
latitude: lat && parseFloat(lat) ? parseFloat(lat) : constants.DEFAULT_CENTER[0],
|
|
longitude: lng && parseFloat(lng) ? parseFloat(lng) : constants.DEFAULT_CENTER[1],
|
|
zoom: zoom && parseFloat(zoom) ? parseFloat(zoom) : constants.GLOBAL_MIN_ZOOM,
|
|
});
|
|
|
|
const [selectedFeatures, setSelectedFeatures] = useState<MapGeoJSONFeature[]>([]);
|
|
const [detailViewData, setDetailViewData] = useState<IDetailViewInterface>();
|
|
const [transitionInProgress, setTransitionInProgress] = useState<boolean>(false);
|
|
const [geolocationInProgress, setGeolocationInProgress] = useState<boolean>(false);
|
|
const [isMobileMapState, setIsMobileMapState] = useState<boolean>(false);
|
|
const [inMultiSelectMode, setInMultiSelectMode] = useState<boolean>(false);
|
|
const [showTooManyTractsAlert, setShowTooManyTractsAlert] = useState<boolean>(false);
|
|
const [selectTractId, setSelectTractId] = useState<string | undefined>(undefined);
|
|
const {width: windowWidth} = useWindowSize();
|
|
|
|
/**
|
|
* Store the geolocation lock state in local storage. The Geolocation component from MapBox does not
|
|
* expose (API) various geolocation lock/unlock states in the version we are using. This makes it
|
|
* challenging to change the UI state to match the Geolocation state. A work around is to store the
|
|
* geolocation "locked" state in local storage. The local storage state will then be used to show the
|
|
* "Finding location" message. The local storage will be removed everytime the map is reloaded.
|
|
*
|
|
* The "Finding location" message only applies for desktop layouts.
|
|
*/
|
|
// eslint-disable-next-line max-len
|
|
const [isGeolocateLocked, setIsGeolocateLocked, removeGeolocateLock] = useLocalStorage('is-geolocate-locked', false, {raw: true});
|
|
|
|
const mapRef = useRef<MapRef>(null);
|
|
const flags = useFlags();
|
|
const intl = useIntl();
|
|
|
|
const zoomLatLngHash = mapRef.current?.getMap()._hash._getCurrentHash();
|
|
|
|
/**
|
|
* Get the bounding box for one or more features.
|
|
* @param featureList the list of features
|
|
* @returns the bounding box
|
|
*/
|
|
const getFeaturesBbox = (featureList: MapGeoJSONFeature[]): number[] => {
|
|
if (featureList.length === 0) {
|
|
throw new Error('featureList must be a non-empty array to get a bounding box.');
|
|
}
|
|
|
|
// Calculate a max and min lat/lon from all the selected features.
|
|
const minLngList: number[] = [];
|
|
const minLatList: number[] = [];
|
|
const maxLngList: number[] = [];
|
|
const maxLatList: number[] = [];
|
|
featureList.forEach((feature) => {
|
|
const [featMinLng, featMinLat, featMaxLng, featMaxLat] = bbox(feature);
|
|
minLngList.push(featMinLng);
|
|
minLatList.push(featMinLat);
|
|
maxLngList.push(featMaxLng);
|
|
maxLatList.push(featMaxLat);
|
|
});
|
|
const minLng: number = Math.min(...minLngList);
|
|
const minLat: number = Math.max(...minLatList);
|
|
const maxLng: number = Math.max(...maxLngList);
|
|
const maxLat: number = Math.min(...maxLatList);
|
|
return [minLng, minLat, maxLng, maxLat];
|
|
};
|
|
|
|
/**
|
|
* Updates the state with the list of selected features. This function will:
|
|
* - Add the feature to the list if in multi select and the feature does not already exist
|
|
* - Remove the feature from the list if in multi select and the feature does already exist
|
|
* @param feature the feature to add or remove
|
|
* @param isMultiSelect true if in multiselect mode
|
|
* @returns the list of zero or more features
|
|
*/
|
|
const updateSelectedFeatures = (feature: MapGeoJSONFeature, isMultiSelect: boolean): MapGeoJSONFeature[] => {
|
|
if (!feature) return selectedFeatures;
|
|
|
|
// If the feature is in the list then remove it as it is being deselected
|
|
const exists = selectedFeatures.some((item) => item.id === feature.id);
|
|
let featureList: MapGeoJSONFeature[] = selectedFeatures;
|
|
if (exists) {
|
|
featureList = selectedFeatures.filter((item) => item.id !== feature.id);
|
|
setShowTooManyTractsAlert(false);
|
|
} else if (selectedFeatures.length < MAX_SELECTED_TRACTS) {
|
|
// Add the feature to the list if in multi select, otherwise replace the list
|
|
// with just this one feature.
|
|
featureList = isMultiSelect ?
|
|
[...selectedFeatures, feature] :
|
|
[feature];
|
|
} else {
|
|
setShowTooManyTractsAlert(true);
|
|
}
|
|
setSelectedFeatures(featureList);
|
|
|
|
if (!inMultiSelectMode) {
|
|
// Turn on multi select mode any time we select more than one tract.
|
|
setInMultiSelectMode(featureList.length > 1);
|
|
}
|
|
|
|
return featureList;
|
|
};
|
|
|
|
/**
|
|
* Selects the provided feature on the map.
|
|
* @param feature the feature to select
|
|
* @param isMultiSelectKeyDown true if the multi select key is down
|
|
*/
|
|
const selectFeaturesOnMap = (feature: IMapFeature, isMultiSelectKeyDown: boolean = false) => {
|
|
const featuresList = updateSelectedFeatures(feature, isMultiSelectKeyDown || inMultiSelectMode);
|
|
if (featuresList.length > 0) {
|
|
const [minLng, minLat, maxLng, maxLat] = getFeaturesBbox(featuresList);
|
|
|
|
// Go to area of the selected feature(s)
|
|
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
|
|
* territory controls and it will listen to clicks on the map.
|
|
*
|
|
* It will NOT listen to clicks into the search field or the zoom controls. These clickHandlers are
|
|
* captured in their own respective components.
|
|
*/
|
|
const onClick = (event: MapEvent | React.MouseEvent<HTMLButtonElement>) => {
|
|
// Stop all propagation / bubbling / capturing
|
|
event.preventDefault();
|
|
(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
|
|
// if the click is coming from a territory control
|
|
if (event.target && (event.target as HTMLElement).id) {
|
|
const buttonID = event.target && (event.target as HTMLElement).id;
|
|
|
|
switch (buttonID) {
|
|
case '48':
|
|
goToPlace(constants.LOWER_48_BOUNDS, true);
|
|
break;
|
|
case 'AK':
|
|
goToPlace(constants.ALASKA_BOUNDS, true);
|
|
break;
|
|
case 'HI':
|
|
goToPlace(constants.HAWAII_BOUNDS, true);
|
|
break;
|
|
case 'PR':
|
|
goToPlace(constants.PUERTO_RICO_BOUNDS, true);
|
|
break;
|
|
case 'GU':
|
|
goToPlace(constants.GUAM_BOUNDS, true);
|
|
break;
|
|
case 'AS':
|
|
goToPlace(constants.AMERICAN_SAMOA_BOUNDS, true);
|
|
break;
|
|
case 'MP':
|
|
goToPlace(constants.MARIANA_ISLAND_BOUNDS, true);
|
|
break;
|
|
case 'VI':
|
|
goToPlace(constants.US_VIRGIN_ISLANDS_BOUNDS, true);
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
} 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];
|
|
|
|
// @ts-ignore
|
|
selectFeaturesOnMap(feature, event.srcEvent.ctrlKey);
|
|
}
|
|
};
|
|
|
|
const onLoad = () => {
|
|
if (typeof window !== 'undefined' && window.Cypress && mapRef.current) {
|
|
window.underlyingMap = mapRef.current.getMap();
|
|
}
|
|
|
|
// When map loads remove the geolocate lock boolean in local storage
|
|
removeGeolocateLock();
|
|
|
|
if (isMobile) setIsMobileMapState(true);
|
|
};
|
|
|
|
|
|
/**
|
|
* This function will move the map (with easing) to the given lat/long bounds.
|
|
*
|
|
* When a user clicks on a tracts vs a territory button, the zoom level returned by the fitBounds
|
|
* function differ. Given that we want to handle the zoom differently depending on these two cases, we
|
|
* introduce a boolean, isTerritory that will allow the zoom level to be set depending on what the user
|
|
* is interacting with, namely a tract vs a territory button.
|
|
*
|
|
* @param {LngLatBoundsLike} bounds
|
|
* @param {boolean} isTerritory
|
|
*/
|
|
const goToPlace = (bounds: LngLatBoundsLike, isTerritory = false, selectTractId: string | undefined = undefined) => {
|
|
const newViewPort = new WebMercatorViewport({height: viewport.height!, width: viewport.width!});
|
|
const {longitude, latitude, zoom} = newViewPort.fitBounds(
|
|
bounds as [[number, number], [number, number]], {
|
|
// padding: 200, // removing padding and offset in favor of a zoom offset below
|
|
// offset: [0, -100],
|
|
});
|
|
|
|
/**
|
|
* When some tracts are selected, they end up too far zoomed in, causing some census tracts to
|
|
* only show a portion of the tract in the viewport. We reduce the zoom level by 1 to allow
|
|
* more space around the selected tract.
|
|
*
|
|
* Given that the high zoom tiles only go to zoom level 5, if the corrected zoom level (zoom - 1) is
|
|
* less than MIN_ZOOM_FEATURE_BORDER, then we floor the zoom to MIN_ZOOM_FEATURE_BORDER + .1 (which
|
|
* is 5.1 as of this comment)
|
|
*/
|
|
// eslint-disable-next-line max-len
|
|
const featureSelectionZoomLevel = (zoom - 1) < constants.GLOBAL_MIN_ZOOM_FEATURE_BORDER + .1 ?
|
|
constants.GLOBAL_MIN_ZOOM_FEATURE_BORDER :
|
|
zoom - 1;
|
|
|
|
setViewport({
|
|
...viewport,
|
|
longitude,
|
|
latitude,
|
|
zoom: isTerritory ? zoom : featureSelectionZoomLevel,
|
|
transitionDuration: 1000,
|
|
transitionInterpolator: new FlyToInterpolator(),
|
|
transitionEasing: d3.easeCubic,
|
|
});
|
|
|
|
// Set the tract ID to be selected if any.
|
|
setSelectTractId(selectTractId);
|
|
};
|
|
|
|
const onTransitionStart = () => {
|
|
setTransitionInProgress(true);
|
|
};
|
|
|
|
const onTransitionEnd = () => {
|
|
setTransitionInProgress(false);
|
|
|
|
/*
|
|
If there is a tract ID to be selected then do so once the map has finished moving.
|
|
Note that setting the viewpoint to move the map as done in this component does not
|
|
trigger a moveend or idle event like when using flyTo or easeTo.
|
|
*/
|
|
if (selectTractId) {
|
|
// Search for features in the map that have the tract ID.
|
|
const geoidSearchResults = mapRef.current?.getMap()
|
|
.querySourceFeatures(constants.HIGH_ZOOM_SOURCE_NAME, {
|
|
sourceLayer: constants.SCORE_SOURCE_LAYER,
|
|
validate: true,
|
|
filter: ['==', constants.GEOID_PROPERTY, selectTractId],
|
|
});
|
|
if (geoidSearchResults && geoidSearchResults.length > 0) {
|
|
// TODO, support searching for a list of tracts
|
|
selectFeaturesOnMap(geoidSearchResults[0]);
|
|
}
|
|
setSelectTractId(undefined);
|
|
}
|
|
};
|
|
|
|
const onGeolocate = () => {
|
|
setGeolocationInProgress(false);
|
|
|
|
// set local storage that location was locked on this app at some point
|
|
setIsGeolocateLocked(true);
|
|
};
|
|
|
|
const onClickGeolocate = () => {
|
|
setGeolocationInProgress(true);
|
|
};
|
|
|
|
/**
|
|
* Handler for when there is a change in the multi select side panel.
|
|
* @param feature the feature that was added or removed
|
|
*/
|
|
const onReportDeleteTract = (feature: MapGeoJSONFeature) => {
|
|
updateSelectedFeatures(feature, true);
|
|
};
|
|
|
|
/**
|
|
* Handler for when the multi select is finished.
|
|
*/
|
|
const onReportExit = () => {
|
|
// Clear everything
|
|
setSelectedFeatures([]);
|
|
setDetailViewData(undefined);
|
|
setInMultiSelectMode(false);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Grid desktop={{col: 9}} className={styles.j40Map}>
|
|
{/**
|
|
* Note:
|
|
* The MapSearch component is no longer used in this location. It has been moved inside the
|
|
* <ReactMapGL> component itself.
|
|
*
|
|
* It was originally wrapped in a div in order to allow this feature
|
|
* to be behind a feature flag. This was causing a bug for MapSearch to render
|
|
* correctly in a production build. Leaving this comment here in case future flags are
|
|
* needed in this component.
|
|
*
|
|
* When the MapSearch component is placed behind a feature flag without a div wrapping
|
|
* MapSearch, the production build will inject CSS due to the null in the false conditional
|
|
* case. Any changes to this (ie, changes to MapSearch or removing feature flag, etc), should
|
|
* be tested with a production build via:
|
|
* - npm run clean && npm run build && npm run serve
|
|
*
|
|
* to ensure the production build works and that MapSearch and the map (ReactMapGL) render correctly.
|
|
*
|
|
* Any component declarations outside the <ReactMapGL> component may be susceptible to this bug.
|
|
*/}
|
|
|
|
{/**
|
|
* The ReactMapGL component's props are grouped by the API's documentation. The component also has
|
|
* some children.
|
|
*/}
|
|
<ReactMapGL
|
|
// ****** Initialization props: ******
|
|
// access token is j40StylesReadToken
|
|
mapboxApiAccessToken={
|
|
process.env.MAPBOX_STYLES_READ_TOKEN ?
|
|
process.env.MAPBOX_STYLES_READ_TOKEN : ''}
|
|
|
|
// ****** Map state props: ******
|
|
// http://visgl.github.io/react-map-gl/docs/api-reference/interactive-map#map-state
|
|
{...viewport}
|
|
mapStyle={
|
|
process.env.MAPBOX_STYLES_READ_TOKEN ?
|
|
'mapbox://styles/justice40/cl9g30qh7000p15l9cp1ftw16' :
|
|
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'
|
|
}
|
|
width="100%"
|
|
// Ajusting this height with a conditional statement will not render the map on staging.
|
|
// The reason for this issue is unknown. Consider styling the parent container via SASS.
|
|
height="100%"
|
|
mapOptions={{hash: true}}
|
|
|
|
|
|
// ****** Interaction option props: ******
|
|
// http://visgl.github.io/react-map-gl/docs/api-reference/interactive-map#interaction-options
|
|
maxZoom={constants.GLOBAL_MAX_ZOOM}
|
|
minZoom={constants.GLOBAL_MIN_ZOOM}
|
|
dragRotate={false}
|
|
touchRotate={false}
|
|
// eslint-disable-next-line max-len
|
|
interactiveLayerIds={
|
|
[
|
|
constants.HIGH_ZOOM_LAYER_ID,
|
|
constants.PRIORITIZED_HIGH_ZOOM_LAYER_ID,
|
|
]
|
|
}
|
|
|
|
|
|
// ****** Callback props: ******
|
|
// http://visgl.github.io/react-map-gl/docs/api-reference/interactive-map#callbacks
|
|
onViewportChange={setViewport}
|
|
onClick={onClick}
|
|
onLoad={onLoad}
|
|
onTransitionStart={onTransitionStart}
|
|
onTransitionEnd={onTransitionEnd}
|
|
ref={mapRef}
|
|
data-cy={'reactMapGL'}
|
|
>
|
|
|
|
{ /* Tribal layer is baked into Mapbox source,
|
|
* only render here if we're not using that
|
|
**/
|
|
process.env.MAPBOX_STYLES_READ_TOKEN ||
|
|
<MapTribalLayer />
|
|
}
|
|
|
|
<MapTractLayers
|
|
selectedFeatures={selectedFeatures}
|
|
/>
|
|
|
|
{/* This is the first overlayed row on the map: Search and Geolocation */}
|
|
<div className={styles.mapHeaderRow}>
|
|
<MapSearch goToPlace={goToPlace} />
|
|
|
|
{/* Geolocate Icon */}
|
|
<div className={styles.geolocateBox}>
|
|
{
|
|
windowWidth > constants.USWDS_BREAKPOINTS.MOBILE_LG - 1 &&
|
|
<div className={
|
|
(geolocationInProgress && !isGeolocateLocked) ?
|
|
styles.geolocateMessage :
|
|
styles.geolocateMessageHide
|
|
}>
|
|
{intl.formatMessage(EXPLORE_COPY.MAP.GEOLOC_MSG_LOCATING)}
|
|
</div>
|
|
}
|
|
<GeolocateControl
|
|
positionOptions={{enableHighAccuracy: true}}
|
|
onGeolocate={onGeolocate}
|
|
onClick={onClickGeolocate}
|
|
trackUserLocation={windowWidth < constants.USWDS_BREAKPOINTS.MOBILE_LG}
|
|
showUserHeading={windowWidth < constants.USWDS_BREAKPOINTS.MOBILE_LG}
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* This is the second row overlayed on the map, it will add the navigation controls
|
|
of the zoom in and zoom out buttons */}
|
|
{windowWidth > constants.USWDS_BREAKPOINTS.MOBILE_LG && <NavigationControl
|
|
showCompass={false}
|
|
className={styles.navigationControl}
|
|
/>}
|
|
|
|
{/* This is the third row overlayed on the map, it will show shortcut buttons to
|
|
pan/zoom to US territories */}
|
|
{windowWidth > constants.USWDS_BREAKPOINTS.MOBILE_LG &&
|
|
<TerritoryFocusControl onClick={onClick} />}
|
|
|
|
{/* Enable fullscreen pop-up behind a feature flag */}
|
|
{('fs' in flags && detailViewData && !transitionInProgress) && (
|
|
<Popup
|
|
className={styles.j40Popup}
|
|
tipSize={5}
|
|
anchor="top"
|
|
longitude={detailViewData.longitude!}
|
|
latitude={detailViewData.latitude!}
|
|
closeOnClick={true}
|
|
onClose={setDetailViewData}
|
|
captureScroll={true}
|
|
>
|
|
<AreaDetail
|
|
properties={detailViewData.properties}
|
|
hash={zoomLatLngHash}
|
|
/>
|
|
</Popup>
|
|
)}
|
|
{'fs' in flags ? <FullscreenControl className={styles.fullscreenControl} /> : ''}
|
|
|
|
</ReactMapGL>
|
|
</Grid>
|
|
|
|
<Grid desktop={{col: 3}}>
|
|
{inMultiSelectMode ?
|
|
<CreateReportPanel
|
|
className={styles.mapInfoPanel}
|
|
featureList={selectedFeatures}
|
|
deleteTractHandler={onReportDeleteTract}
|
|
exitHandler={onReportExit}
|
|
maxNumTracts={MAX_SELECTED_TRACTS}
|
|
showTooManyTractsAlert={showTooManyTractsAlert}
|
|
/> :
|
|
<MapInfoPanel
|
|
className={styles.mapInfoPanel}
|
|
featureProperties={detailViewData?.properties}
|
|
hash={zoomLatLngHash}
|
|
/>
|
|
}
|
|
</Grid>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default J40Map;
|