diff --git a/client/src/components/J40Map.tsx b/client/src/components/J40Map.tsx index f56c7f6c..109684a3 100644 --- a/client/src/components/J40Map.tsx +++ b/client/src/components/J40Map.tsx @@ -1,7 +1,7 @@ /* eslint-disable valid-jsdoc */ /* eslint-disable no-unused-vars */ // External Libs: -import React, {useRef, useState, useMemo} from 'react'; +import React, {useRef, useState} from 'react'; import {Map, MapboxGeoJSONFeature, LngLatBoundsLike} from 'maplibre-gl'; import ReactMapGL, { MapEvent, @@ -12,7 +12,7 @@ import ReactMapGL, { Popup, FlyToInterpolator, FullscreenControl, - MapRef, Source, Layer} from 'react-map-gl'; + MapRef} from 'react-map-gl'; import bbox from '@turf/bbox'; import * as d3 from 'd3-ease'; import {isMobile} from 'react-device-detect'; @@ -26,6 +26,7 @@ import {useFlags} from '../contexts/FlagContext'; import AreaDetail from './AreaDetail'; import MapInfoPanel from './mapInfoPanel'; import MapSearch from './MapSearch'; +import MapTractLayers from './MapTractLayers/MapTractLayers'; import LayerSelector from './LayerSelector'; import TerritoryFocusControl from './territoryFocusControl'; import {getOSBaseMap} from '../data/getOSBaseMap'; @@ -34,9 +35,7 @@ import {getOSBaseMap} from '../data/getOSBaseMap'; import 'maplibre-gl/dist/maplibre-gl.css'; import * as constants from '../data/constants'; import * as styles from './J40Map.module.scss'; -import * as COMMON_COPY from '../data/copy/common'; - - +import MapTribalLayer from './MapTribalLayer/MapTribalLayer'; declare global { interface Window { Cypress?: object; @@ -56,49 +55,6 @@ export interface IDetailViewInterface { properties: constants.J40Properties, }; -/** - * This function will determine the URL for the map tiles. It will read in a string that will designate either - * high or low tiles. It will allow to overide the URL to the pipeline staging tile URL via feature flag. - * Lastly, it allows to set the tiles to be local or via the CDN as well. - * - * @param {string} tilesetName - * @returns {string} - */ -export const featureURLForTilesetName = (tilesetName: string): string => { - const flags = useFlags(); - - const pipelineStagingBaseURL = `https://justice40-data.s3.amazonaws.com/data-pipeline-staging`; - const XYZ_SUFFIX = '{z}/{x}/{y}.pbf'; - - if ('stage_hash' in flags) { - // Check if the stage_hash is valid - const regex = /^[0-9]{4}\/[a-f0-9]{40}$/; - if (!regex.test(flags['stage_hash'])) { - console.error(COMMON_COPY.CONSOLE_ERROR.STAGE_URL); - } - - return `${pipelineStagingBaseURL}/${flags['stage_hash']}/data/score/tiles/${tilesetName}/${XYZ_SUFFIX}`; - } 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_DATA_PIPELINE_SCORE_PATH; - - return [ - featureTileBaseURL, - featureTilePath, - process.env.GATSBY_MAP_TILES_PATH, - tilesetName, - XYZ_SUFFIX, - ].join('/'); - } -}; - const J40Map = ({location}: IJ40Interface) => { /** @@ -128,13 +84,13 @@ const J40Map = ({location}: IJ40Interface) => { const [transitionInProgress, setTransitionInProgress] = useState(false); const [geolocationInProgress, setGeolocationInProgress] = useState(false); const [isMobileMapState, setIsMobileMapState] = useState(false); + const [censusSelected, setCensusSelected] = useState(true); const {width: windowWidth} = useWindowSize(); const mapRef = useRef(null); const flags = useFlags(); const selectedFeatureId = (selectedFeature && selectedFeature.id) || ''; - const filter = useMemo(() => ['in', constants.GEOID_PROPERTY, selectedFeatureId], [selectedFeature]); const zoomLatLngHash = mapRef.current?.getMap()._hash._getCurrentHash(); @@ -352,7 +308,7 @@ const J40Map = ({location}: IJ40Interface) => { {/* This will allow to select between the census tract layer and the tribal lands layer */} - + {/** * The ReactMapGL component's props are grouped by the API's documentation. The component also has @@ -368,7 +324,7 @@ const J40Map = ({location}: IJ40Interface) => { // ****** 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 ? mapBoxBaseLayer : getOSBaseMap()} + mapStyle={process.env.MAPBOX_STYLES_READ_TOKEN ? mapBoxBaseLayer : getOSBaseMap(censusSelected)} 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. @@ -382,7 +338,8 @@ const J40Map = ({location}: IJ40Interface) => { minZoom={constants.GLOBAL_MIN_ZOOM} dragRotate={false} touchRotate={false} - interactiveLayerIds={[constants.HIGH_ZOOM_LAYER_ID, constants.PRIORITIZED_HIGH_ZOOM_LAYER_ID]} + // eslint-disable-next-line max-len + interactiveLayerIds={censusSelected ? [constants.HIGH_ZOOM_LAYER_ID, constants.PRIORITIZED_HIGH_ZOOM_LAYER_ID] : [constants.TRIBAL_LAYER_ID]} // ****** Callback props: ****** @@ -396,96 +353,13 @@ const J40Map = ({location}: IJ40Interface) => { ref={mapRef} data-cy={'reactMapGL'} > - {/** - * The low zoom source - */} - - {/* Low zoom layer - prioritized features only */} - ', constants.SCORE_PROPERTY_LOW, constants.SCORE_BOUNDARY_THRESHOLD]} - type='fill' - paint={{ - 'fill-color': constants.PRIORITIZED_FEATURE_FILL_COLOR, - 'fill-opacity': constants.LOW_ZOOM_PRIORITIZED_FEATURE_FILL_OPACITY}} - maxzoom={constants.GLOBAL_MAX_ZOOM_LOW} - minzoom={constants.GLOBAL_MIN_ZOOM_LOW} - /> - - - {/** - * The high zoom source - */} - - - {/* High zoom layer - non-prioritized features only */} - - - {/* High zoom layer - prioritized features only */} - ', constants.SCORE_PROPERTY_HIGH, constants.SCORE_BOUNDARY_THRESHOLD]} - type='fill' - paint={{ - 'fill-color': constants.PRIORITIZED_FEATURE_FILL_COLOR, - 'fill-opacity': constants.HIGH_ZOOM_PRIORITIZED_FEATURE_FILL_OPACITY, - }} - minzoom={constants.GLOBAL_MIN_ZOOM_HIGH} - /> - - {/* High zoom layer - controls the border between features */} - - - {/* High zoom layer - border styling around the selected feature */} - - + {/* Load either the Tribal layer or census layer */} + { + censusSelected ? + : + + } {/* This will add the navigation controls of the zoom in and zoom out buttons */} { windowWidth > constants.USWDS_BREAKPOINTS.MOBILE_LG && { - const {asFragment} = render( - - - , - ); + it('checks if component renders census tracts selected', () => { + const {asFragment} = render( + + {}}/> + , + ); + expect(asFragment()).toMatchSnapshot(); + }); - it('checks if component renders', () => { + it('checks if component renders tribal selected', () => { + const {asFragment} = render( + + {}}/> + , + ); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/client/src/components/LayerSelector/LayerSelector.tsx b/client/src/components/LayerSelector/LayerSelector.tsx index 92ee25d4..933133c8 100644 --- a/client/src/components/LayerSelector/LayerSelector.tsx +++ b/client/src/components/LayerSelector/LayerSelector.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useState, Dispatch} from 'react'; import {useIntl} from 'gatsby-plugin-intl'; import {Button, ButtonGroup} from '@trussworks/react-uswds'; import {useWindowSize} from 'react-use'; @@ -7,9 +7,13 @@ import {useWindowSize} from 'react-use'; import * as styles from './LayerSelector.module.scss'; import * as EXPLORE_COPY from '../../data/copy/explore'; -const LayerSelector = () => { +interface ILayerSelector { + censusSelected: boolean, + setCensusSelected: Dispatch, +} + +const LayerSelector = ({censusSelected, setCensusSelected}:ILayerSelector) => { const intl = useIntl(); - const [censusSelected, setCensusSelected] = useState(true); /** * At compile-time, the width/height returned by useWindowSize will be X. When the client requests the @@ -45,6 +49,7 @@ const LayerSelector = () => { return (
+ {/* // Todo: set i18n here */} + +
  • + +
  • + +
    + +`; diff --git a/client/src/components/MapTractLayers/MapTractLayers.tsx b/client/src/components/MapTractLayers/MapTractLayers.tsx new file mode 100644 index 00000000..60195293 --- /dev/null +++ b/client/src/components/MapTractLayers/MapTractLayers.tsx @@ -0,0 +1,155 @@ +import React, {useMemo} from 'react'; +import {Source, Layer} from 'react-map-gl'; + +// Contexts: +import {useFlags} from '../../contexts/FlagContext'; + +import * as constants from '../../data/constants'; +import * as COMMON_COPY from '../../data/copy/common'; + +// Todo: Update with real types if this works: +interface IMapTractLayers { + selectedFeatureId: any, + selectedFeature: any +} + +/** + * This function will determine the URL for the map tiles. It will read in a string that will designate either + * high or low tiles. It will allow to overide the URL to the pipeline staging tile URL via feature flag. + * Lastly, it allows to set the tiles to be local or via the CDN as well. + * + * @param {string} tilesetName + * @return {string} + */ +export const featureURLForTilesetName = (tilesetName: string): string => { + const flags = useFlags(); + + const pipelineStagingBaseURL = `https://justice40-data.s3.amazonaws.com/data-pipeline-staging`; + const XYZ_SUFFIX = '{z}/{x}/{y}.pbf'; + + if ('stage_hash' in flags) { + // Check if the stage_hash is valid + const regex = /^[0-9]{4}\/[a-f0-9]{40}$/; + if (!regex.test(flags['stage_hash'])) { + console.error(COMMON_COPY.CONSOLE_ERROR.STAGE_URL); + } + + return `${pipelineStagingBaseURL}/${flags['stage_hash']}/data/score/tiles/${tilesetName}/${XYZ_SUFFIX}`; + } 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_DATA_PIPELINE_SCORE_PATH; + + return [ + featureTileBaseURL, + featureTilePath, + process.env.GATSBY_MAP_TILES_PATH, + tilesetName, + XYZ_SUFFIX, + ].join('/'); + } +}; + +const MapTractLayers = ({selectedFeatureId, selectedFeature}: IMapTractLayers) => { + const filter = useMemo(() => ['in', constants.GEOID_PROPERTY, selectedFeatureId], [selectedFeature]); + + return ( + <> + + + {/* Low zoom layer - prioritized features only */} + ', constants.SCORE_PROPERTY_LOW, constants.SCORE_BOUNDARY_THRESHOLD]} + type='fill' + paint={{ + 'fill-color': constants.PRIORITIZED_FEATURE_FILL_COLOR, + 'fill-opacity': constants.LOW_ZOOM_PRIORITIZED_FEATURE_FILL_OPACITY}} + maxzoom={constants.GLOBAL_MAX_ZOOM_LOW} + minzoom={constants.GLOBAL_MIN_ZOOM_LOW} + /> + + + {/** + * The high zoom source + */} + + + {/* High zoom layer - non-prioritized features only */} + + + {/* High zoom layer - prioritized features only */} + ', constants.SCORE_PROPERTY_HIGH, constants.SCORE_BOUNDARY_THRESHOLD]} + type='fill' + paint={{ + 'fill-color': constants.PRIORITIZED_FEATURE_FILL_COLOR, + 'fill-opacity': constants.HIGH_ZOOM_PRIORITIZED_FEATURE_FILL_OPACITY, + }} + minzoom={constants.GLOBAL_MIN_ZOOM_HIGH} + /> + + {/* High zoom layer - controls the border between features */} + + + {/* High zoom layer - border styling around the selected feature */} + + + + ); +}; + +export default MapTractLayers; diff --git a/client/src/components/MapTractLayers/index.tsx b/client/src/components/MapTractLayers/index.tsx new file mode 100644 index 00000000..5721cd84 --- /dev/null +++ b/client/src/components/MapTractLayers/index.tsx @@ -0,0 +1,3 @@ +import MapTractLayers from './MapTractLayers'; + +export default MapTractLayers; diff --git a/client/src/components/MapTribalLayer/MapTribalLayer.tsx b/client/src/components/MapTribalLayer/MapTribalLayer.tsx new file mode 100644 index 00000000..37baa07c --- /dev/null +++ b/client/src/components/MapTribalLayer/MapTribalLayer.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import {Source, Layer} from 'react-map-gl'; + +import * as constants from '../../data/constants'; + +/** + * This function will determine the URL for the tribal tiles. + * @return {string} + */ +export const tribalURL = (): string => { + const XYZ_SUFFIX = '{z}/{x}/{y}.pbf'; + + return [ + process.env.GATSBY_CDN_TILES_BASE_URL, + process.env.GATSBY_DATA_PIPELINE_TRIBAL_PATH, + process.env.GATSBY_MAP_TILES_PATH, + XYZ_SUFFIX, + ].join('/'); +}; + +const MapTribalLayer = () => { + return ( + + + {/* Low zoom layer - prioritized features only */} + ', constants.SCORE_PROPERTY_LOW, constants.SCORE_BOUNDARY_THRESHOLD]} + type='fill' + paint={{ + 'fill-color': constants.TRIBAL_FILL_COLOR, + 'fill-opacity': constants.TRIBAL_FEATURE_FILL_OPACITY}} + minzoom={constants.TRIBAL_MIN_ZOOM} + maxzoom={constants.TRIBAL_MAX_ZOOM} + /> + + {/* Tribal layer - controls the border between features */} + + + {/* Tribal layer - border styling around the selected feature */} + + + ); +}; + +export default MapTribalLayer; +// diff --git a/client/src/components/MapTribalLayer/index.tsx b/client/src/components/MapTribalLayer/index.tsx new file mode 100644 index 00000000..20465589 --- /dev/null +++ b/client/src/components/MapTribalLayer/index.tsx @@ -0,0 +1,3 @@ +import MapTribalLayer from './MapTribalLayer'; + +export default MapTribalLayer; diff --git a/client/src/data/constants.tsx b/client/src/data/constants.tsx index 09f38ba4..c30b4e28 100644 --- a/client/src/data/constants.tsx +++ b/client/src/data/constants.tsx @@ -25,6 +25,9 @@ export type J40Properties = { [key: string]: any }; // ****** SIDE PANEL BACKEND SIGNALS *********** +// Tribal signals +export const TRIBAL_ID = 'tribalId'; + // Set the threshold percentile used by most indicators in the side panel export const DEFAULT_THRESHOLD_PERCENTILE = 90; @@ -187,15 +190,18 @@ export const ISLAND_AREA_LOW_HS_EDU = 'IALHE'; export const BASE_MAP_SOURCE_NAME = 'base-map-source-name'; export const HIGH_ZOOM_SOURCE_NAME = 'high-zoom-source-name'; export const LOW_ZOOM_SOURCE_NAME = 'low-zoom-source-name'; +export const TRIBAL_SOURCE_NAME = 'tribal-source-name'; // Layer ID constants export const SCORE_SOURCE_LAYER = 'blocks'; // The name of the layer within the tiles that contains the score +export const TRIBAL_SOURCE_LAYER = 'blocks'; export const BASE_MAP_LAYER_ID = 'base-map-layer-id'; export const HIGH_ZOOM_LAYER_ID = 'high-zoom-layer-id'; export const PRIORITIZED_HIGH_ZOOM_LAYER_ID = 'prioritized-high-zoom-layer-id'; export const LOW_ZOOM_LAYER_ID = 'low-zoom-layer-id'; export const FEATURE_BORDER_LAYER_ID = 'feature-border-layer-id'; export const SELECTED_FEATURE_BORDER_LAYER_ID = 'selected-feature-border-layer-id'; +export const TRIBAL_LAYER_ID = 'tribal-layer-id'; // Used in layer filters: export const SCORE_PROPERTY_LOW = 'M_SCORE'; @@ -213,18 +219,26 @@ export const GLOBAL_MAX_ZOOM_HIGH = 11; export const GLOBAL_MIN_ZOOM_FEATURE_BORDER = 5; export const GLOBAL_MAX_ZOOM_FEATURE_BORDER = 22; +export const TRIBAL_MIN_ZOOM = 3; +export const TRIBAL_MAX_ZOOM = 22; // Opacity export const FEATURE_BORDER_OPACITY = 0.5; export const HIGH_ZOOM_PRIORITIZED_FEATURE_FILL_OPACITY = 0.3; export const LOW_ZOOM_PRIORITIZED_FEATURE_FILL_OPACITY = 0.6; export const NON_PRIORITIZED_FEATURE_FILL_OPACITY = 0; +export const TRIBAL_FEATURE_FILL_OPACITY = 0.3; // Colors export const FEATURE_BORDER_COLOR = '#4EA5CF'; export const SELECTED_FEATURE_BORDER_COLOR = '#1A4480'; export const PRIORITIZED_FEATURE_FILL_COLOR = '#768FB3'; +export const TRIBAL_BORDER_COLOR = '#0000FF'; +export const SELECTED_TRIBAL_BORDER_COLOR = '#FF0000'; +export const TRIBAL_FILL_COLOR = '#00FF00'; + + // Widths export const FEATURE_BORDER_WIDTH = 0.8; export const SELECTED_FEATURE_BORDER_WIDTH = 5.0; diff --git a/client/src/data/getOSBaseMap.tsx b/client/src/data/getOSBaseMap.tsx index 8e756dce..ca2b38fc 100644 --- a/client/src/data/getOSBaseMap.tsx +++ b/client/src/data/getOSBaseMap.tsx @@ -1,6 +1,7 @@ import {Style} from 'maplibre-gl'; import * as constants from '../data/constants'; -import {featureURLForTilesetName} from '../components/J40Map'; +import {featureURLForTilesetName} from '../components/MapTractLayers/MapTractLayers'; +import {tribalURL} from '../components/MapTribalLayer/MapTribalLayer'; // *********** BASE MAP SOURCES *************** const imageSuffix = constants.isMobile ? '' : '@2x'; @@ -25,8 +26,12 @@ const cartoLightBaseLayer = { // Utility function to get OpenSource base maps that are in accordance to JSON spec of MapBox // https://docs.mapbox.com/mapbox-gl-js/style-spec/ -export const getOSBaseMap = () : Style => { - return { +export const getOSBaseMap = (censusSelected: boolean) : Style => { + return !censusSelected ? { + + /** + * Tribal Source + */ 'version': 8, /** @@ -44,6 +49,68 @@ export const getOSBaseMap = () : Style => { 'maxzoom': constants.GLOBAL_MAX_ZOOM, }, + /** + * Tribal source + */ + [constants.TRIBAL_SOURCE_NAME]: { + 'type': 'vector', + 'promoteId': constants.TRIBAL_ID, + 'tiles': [tribalURL()], + 'minzoom': constants.TRIBAL_MIN_ZOOM, + 'maxzoom': constants.TRIBAL_MAX_ZOOM, + }, + }, + + + /** + * Tribal Layers + */ + 'layers': [ + + // The baseMapLayer + { + 'id': constants.BASE_MAP_LAYER_ID, + 'source': constants.BASE_MAP_SOURCE_NAME, + 'type': 'raster', + 'minzoom': constants.GLOBAL_MIN_ZOOM, + 'maxzoom': constants.GLOBAL_MAX_ZOOM, + }, + + /** + * Tribal layer + */ + { + 'id': constants.TRIBAL_LAYER_ID, + 'source': constants.TRIBAL_SOURCE_NAME, + 'source-layer': constants.TRIBAL_SOURCE_LAYER, + 'type': 'fill', + 'paint': { + 'fill-color': constants.TRIBAL_FILL_COLOR, + 'fill-opacity': constants.TRIBAL_FEATURE_FILL_OPACITY, + }, + 'minzoom': constants.TRIBAL_MIN_ZOOM, + 'maxzoom': constants.TRIBAL_MAX_ZOOM, + }, + ], + } : + { + 'version': 8, + + /** + * Census Tract Source + * */ + 'sources': { + + /** + * The base map source source allows us to define where the tiles can be fetched from. + */ + [constants.BASE_MAP_SOURCE_NAME]: { + 'type': 'raster', + 'tiles': cartoLightBaseLayer.noLabels, + 'minzoom': constants.GLOBAL_MIN_ZOOM, + 'maxzoom': constants.GLOBAL_MAX_ZOOM, + }, + // The High zoom source: [constants.HIGH_ZOOM_SOURCE_NAME]: { // It is only shown at high zoom levels to avoid performance issues at lower zooms