adds map side panel (#406)

* initial map side panel

* componentize MapSidePanel

* remove selection from J40Map

* adds isFeatureSelected to toggle component

* filters data from server for client UI

* styling and refactor

* added TODO

* adds styling to intro and pairing feedback

* add mobile styling

* adds popup back to fs feature flag

* adds tests and aria roles

* makes mobile content same as desktop

* prettier update

* initial e2e mapSidePanel test

* adds cypress tests on desktop and mobile

* adds sass util and updates cypress tests

* cleans up tests

* reverts tsconfig file

* fixes map alignment

* renaming and using constants

* renaming sidePanel to infoPanel

* intl messaging

* adds snapshot testing and utility sass file

* PR feedback
- adds intl messages
- adds data-cy attr to cy tests
- snapshot testing for unit tests
- fixes bug where side panel extends past the map
- moves all wrapper content in MapWrapper

* logs isMobile to troubleshoot deployed PR

* adds react-device-detect for isMobile detection

* adds new instance of map for mobile

* adds instance

* adds isMobile to state

* tests the fix for mobile map view on PR

* PR review feedback
- localize MapIntroduction
- update snapshot tests
- QA feedback
- constants.isMobile points to react-device-detect
This commit is contained in:
Vim 2021-07-27 12:05:25 -07:00 committed by GitHub
parent a787bd71ab
commit 36f43b2d44
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1430 additions and 27185 deletions

View file

@ -0,0 +1,376 @@
{
"areaDetail.geographicInfo.censusBlockGroup": [
{
"type": 0,
"value": "Census block group:"
}
],
"areaDetail.geographicInfo.county": [
{
"type": 0,
"value": "County:"
}
],
"areaDetail.geographicInfo.population": [
{
"type": 0,
"value": "Population:"
}
],
"areaDetail.geographicInfo.state": [
{
"type": 0,
"value": "State:"
}
],
"areaDetail.indicator.education": [
{
"type": 0,
"value": "Education"
}
],
"areaDetail.indicator.houseBurden": [
{
"type": 0,
"value": "Housing Burden"
}
],
"areaDetail.indicator.linguisticIsolation": [
{
"type": 0,
"value": "Linguistic Isolation"
}
],
"areaDetail.indicator.poverty": [
{
"type": 0,
"value": "Poverty"
}
],
"areaDetail.indicator.unemployment": [
{
"type": 0,
"value": "Unemployment"
}
],
"areaDetail.indicators.indicatorColumnHeader": [
{
"type": 0,
"value": "INDICATORS"
}
],
"areaDetail.indicators.percentileColumnHeader": [
{
"type": 0,
"value": "PERCENTILE (0-100)"
}
],
"areaDetail.priorityInfo.categorization": [
{
"type": 0,
"value": "Categorization"
}
],
"areaDetail.priorityInfo.cumulativeIndexScore": [
{
"type": 0,
"value": "Cumulative Index Score"
}
],
"areaDetail.priorityInfo.percentile": [
{
"type": 0,
"value": "percentile"
}
],
"areasOfInterest.climate": [
{
"type": 0,
"value": "Climate change"
}
],
"areasOfInterest.energy": [
{
"type": 0,
"value": "Clean energy and energy efficiency"
}
],
"areasOfInterest.housing": [
{
"type": 0,
"value": "Affordable and sustainable housing"
}
],
"areasOfInterest.pollution": [
{
"type": 0,
"value": "Remediation of legacy pollution"
}
],
"areasOfInterest.training": [
{
"type": 0,
"value": "Training and workforce development"
}
],
"areasOfInterest.transit": [
{
"type": 0,
"value": "Clean transit"
}
],
"areasOfInterest.water": [
{
"type": 0,
"value": "Clean water infrastructure"
}
],
"footer.arialabel": [
{
"type": 0,
"value": "Footer navigation"
}
],
"footer.findcontactlink": [
{
"type": 0,
"value": "Find a contact at USA.gov"
}
],
"footer.foialink": [
{
"type": 0,
"value": "Freedom of Information Act (FOIA)"
}
],
"footer.logo.title": [
{
"type": 0,
"value": "Council on Environmental Quality"
}
],
"footer.moreinfoheader": [
{
"type": 0,
"value": "More Information"
}
],
"footer.privacylink": [
{
"type": 0,
"value": "Privacy Policy"
}
],
"footer.questionsheader": [
{
"type": 0,
"value": "Have a question about government services?"
}
],
"footer.whitehouselogoalt": [
{
"type": 0,
"value": "Whitehouse logo"
}
],
"header.about": [
{
"type": 0,
"value": "About"
}
],
"header.contact": [
{
"type": 0,
"value": "Contact"
}
],
"header.explore": [
{
"type": 0,
"value": "Explore the tool"
}
],
"header.methodology": [
{
"type": 0,
"value": "Methodology"
}
],
"header.timeline": [
{
"type": 0,
"value": "Timeline"
}
],
"header.title": [
{
"type": 0,
"value": "Justice40"
}
],
"index.aboutContent.header": [
{
"type": 0,
"value": "About Justice40"
}
],
"index.aboutContent.p1": [
{
"type": 0,
"value": "In an effort to address historical environmental injustices, President Biden created the Justice40 Initiative on January 27, 2021. The Justice40 Initiative directs 40% of the benefits from federal investments in seven key areas to overburdened and underserved communities."
}
],
"index.aboutContent.p2": [
{
"type": 0,
"value": "Federal agencies will prioritize benefits using a new climate and economic justice screening tool. This screening tool will be a map that visualizes data to compare the cumulative impacts of environmental, climate, and economic factors. It is being developed by the Council on Environmental Quality (CEQ) with guidance from environmental justice leaders and communities affected by environmental injustices. The first version of the screening tool will be released in July 2021. However, the screening tool and data being used will be continuously updated to better reflect the lived experiences of community members."
}
],
"index.aboutContent.p3": [
{
"type": 0,
"value": "Read more about the Justice40 Initiative in President Bidens "
},
{
"type": 1,
"value": "presidentLink"
}
],
"index.presidentalLinkLabel": [
{
"type": 0,
"value": "Executive Order on Tackling the Climate Crisis at Home and Abroad."
}
],
"index.presidentalLinkUri": [
{
"type": 0,
"value": "https://www.whitehouse.gov/briefing-room/presidential-actions/2021/01/27/executive-order-on-tackling-the-climate-crisis-at-home-and-abroad/"
}
],
"index.section2.header": [
{
"type": 0,
"value": "Areas of Focus"
}
],
"index.section3.header": [
{
"type": 0,
"value": "A Transparent, Community-First Approach"
}
],
"index.section3.inclusive": [
{
"type": 1,
"value": "inlineHeader"
},
{
"type": 0,
"value": " Many areas which lack investments also lack environmental data and would be overlooked using available environmental data. CEQ is actively reaching out to groups that have historically been excluded from decision-making, such as groups in rural and tribal areas, to understand their needs and ask for their input."
}
],
"index.section3.inclusiveLabel": [
{
"type": 0,
"value": "Inclusive:"
}
],
"index.section3.intro": [
{
"type": 0,
"value": "Successful initiatives are guided by direct input from the communities they are serving. CEQ commits to transparency, inclusivity, and iteration in building this screening tool."
}
],
"index.section3.iterative": [
{
"type": 1,
"value": "inlineHeader"
},
{
"type": 0,
"value": " The initial community prioritization list provided by the screening tool is the beginning of a collaborative process in score refinement, rather than a final answer. CEQ has received recommendations on data sets from community interviews, the White House Environmental Justice Advisory Council, and through public comment, but establishing a score that is truly representative will be a long-term, ongoing process. As communities submit feedback and recommendations, CEQ will continue to improve the tools being built and the processes for stakeholder and public engagement."
}
],
"index.section3.iterativeLabel": [
{
"type": 0,
"value": "Iterative:"
}
],
"index.section3.transparent": [
{
"type": 1,
"value": "inlineHeader"
},
{
"type": 0,
"value": " The code and data behind the screening tool are open source, meaning it is available for the public to review and contribute to. This tool is being developed publicly so that communities, academic experts, and anyone whos interested can be involved in the tool-building process."
}
],
"index.section3.transparentLabel": [
{
"type": 0,
"value": "Transparent:"
}
],
"map.territoryFocus.alaska.long": [
{
"type": 0,
"value": "Alaska"
}
],
"map.territoryFocus.alaska.short": [
{
"type": 0,
"value": "AK"
}
],
"map.territoryFocus.focusOn": [
{
"type": 0,
"value": "Focus on "
},
{
"type": 1,
"value": "territory"
}
],
"map.territoryFocus.hawaii.long": [
{
"type": 0,
"value": "Hawaii"
}
],
"map.territoryFocus.hawaii.short": [
{
"type": 0,
"value": "HI"
}
],
"map.territoryFocus.lower48.long": [
{
"type": 0,
"value": "Lower 48"
}
],
"map.territoryFocus.lower48.short": [
{
"type": 0,
"value": "48"
}
],
"map.territoryFocus.puerto_rico.long": [
{
"type": 0,
"value": "Puerto Rico"
}
],
"map.territoryFocus.puerto_rico.short": [
{
"type": 0,
"value": "PR"
}
]
}

View file

@ -0,0 +1,3 @@
export const ENDPOINTS = {
EXPLORE_THE_TOOL: '/en/cejst',
};

View file

@ -0,0 +1,67 @@
// / <reference types="Cypress" />
/*
A risk with this test is that if the feature/area that is currently being selected become non-prioritized, then this
test will fail. However it would be a major win for that area!
*/
import {ENDPOINTS} from './constants';
const devices = [
[1024, 720],
['iphone-6', 'portrait'],
['samsung-s10', 'portrait'],
];
describe('tests that the map side panel renders MapIntroduction component', () => {
devices.forEach((device) => {
it(`should render MapIntroduction component on ${device[0]} x ${device[1]}`, () => {
cy.visit(ENDPOINTS.EXPLORE_THE_TOOL);
cy.viewport(device[0], device[1]);
cy.get('aside').should('be.visible');
});
});
});
/**
* Todo: Ticket #423:
*
* After fixing the PR deployed URL in regards to the mobile view (parent height
* not setting to 44vh) by creating a state variable to force a re-render, this
* cypress test regressed and is now failing. Need to investigate why.
*
* See this ticket for more info:
* https://app.zenhub.com/workspaces/justice40-60993f6e05473d0010ec44e3/issues/usds/justice40-tool/423
*
* Tried
* 1. reloading the page
* 2. forcing the 44vh value via a selector (data-cy and class)
*/
// describe('tests that the map side panel renders AreaDetail component', () => {
// devices.forEach((device) => {
// it(`should render AreaDetail component on ${device[0]} x ${device[1]}`, () => {
// // Only set the viewport for mobile devices:
// cy.visit(ENDPOINTS.EXPLORE_THE_TOOL);
// if (!Number.isInteger(device[0])) cy.viewport(device[0], device[1]);
// cy.reload(true);
// cy.get('div[class*="mapContainer"]').invoke('attr', 'height', '44vh');
// cy.getMap().then((map) => {
// // Loop over the click event simulating zooming by a certain amount
// // The map will end at the end zoom level
// const endZoomLevel = device[0].isInteger ? 11 : 10;
// for (let zoom = 3; zoom <= endZoomLevel; zoom++) {
// cy.get('.mapboxgl-ctrl-zoom-in > .mapboxgl-ctrl-icon').click();
// cy.waitForMapIdle(map);
// }
// cy.get('.overlays').click('bottomRight');
// cy.get('aside').should('be.visible');
// cy.get('[data-cy="score"]').should('be.visible');
// cy.get('[data-cy="indicatorBox"]').should('be.visible');
// cy.get('[data-cy="indicatorBox"]')
// .each((indicator) => cy.wrap(indicator)
// // currently the padding-top on desktop = 1.5rem => 24px
// // currently the padding-top on mobile = .8 rem => 8px
// .should('have.css', 'padding-top', `${Number.isInteger(device[0]) ? '24px' : '8px'}`));
// });
// });
// });
// });

27128
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -79,6 +79,7 @@
"maplibre-gl": ">=1.14.0",
"query-string": "^7.0.0",
"react": "^17.0.2",
"react-device-detect": "^1.17.0",
"react-dom": "^17.0.1",
"react-helmet": "^6.1.0",
"react-intl": "^5.20.4",

View file

@ -1,13 +1,17 @@
$sidebar-background: rgba(35, 55, 75, 0.9);
$sidebar-color: #ffffff;
@import "./areaDetailUtils.scss";
.mapContainer {
height: 676px;
.mapAndInfoPanelContainer {
display: flex;
height: 81vh; //desktop
@media screen and (max-width: $mobileBreakpoint) {
flex-direction: column;
height: fit-content;
}
}
.j40Popup {
max-height: 50%;
overflow-y: scroll;
width: 375px;
}
.navigationControl {
@ -25,3 +29,9 @@ $sidebar-color: #ffffff;
right: 1.25em;
top: 5em;
}
.mapInfoPanel {
border: 2px solid $sidePanelBorderColor;
overflow-y: auto;
max-width: 22rem;
}

View file

@ -1,12 +1,14 @@
declare namespace J40MapModuleScssNamespace {
export interface IJ40MapModuleScss {
mapContainer: string;
mapAndInfoPanelContainer: string;
j40Popup: string;
territoryFocusButton: string;
territoryFocusContainer: string;
navigationControl: string;
fullscreenControl: string;
geolocateControl: string;
detailView: string;
mapInfoPanel: string;
}
}

View file

@ -1,4 +1,5 @@
/* eslint-disable no-unused-vars */
// External Libs:
import React, {MouseEvent, useRef, useState} from 'react';
import {Map, MapboxGeoJSONFeature, LngLatBoundsLike} from 'maplibre-gl';
import ReactMapGL, {
@ -11,13 +12,20 @@ import ReactMapGL, {
FlyToInterpolator,
FullscreenControl,
MapRef} from 'react-map-gl';
import {makeMapStyle} from '../data/mapStyle';
import AreaDetail from './areaDetail';
import bbox from '@turf/bbox';
import * as d3 from 'd3-ease';
import {useFlags} from '../contexts/FlagContext';
import TerritoryFocusControl from './territoryFocusControl';
import {isMobile} from 'react-device-detect';
// Contexts:
import {useFlags} from '../contexts/FlagContext';
// Components:
import TerritoryFocusControl from './territoryFocusControl';
import MapInfoPanel from './mapInfoPanel';
import AreaDetail from './areaDetail';
// Styles and constants
import {makeMapStyle} from '../data/mapStyle';
import 'maplibre-gl/dist/maplibre-gl.css';
import * as constants from '../data/constants';
import * as styles from './J40Map.module.scss';
@ -31,13 +39,14 @@ declare global {
}
interface IDetailViewInterface {
export interface IDetailViewInterface {
latitude: number
longitude: number
zoom: number
properties: constants.J40Properties,
};
const J40Map = () => {
const [viewport, setViewport] = useState<ViewportProps>({
latitude: constants.DEFAULT_CENTER[0],
@ -49,6 +58,8 @@ const J40Map = () => {
const [detailViewData, setDetailViewData] = useState<IDetailViewInterface>();
const [transitionInProgress, setTransitionInProgress] = useState<boolean>(false);
const [geolocationInProgress, setGeolocationInProgress] = useState<boolean>(false);
const [isMobileMapState, setIsMobileMapState] = useState<boolean>(false);
const mapRef = useRef<MapRef>(null);
const flags = useFlags();
@ -66,6 +77,7 @@ const J40Map = () => {
padding: 40,
},
);
// If we've selected a new feature, set 'selected' to false
if (selectedFeature && feature.id !== selectedFeature.id) {
setMapSelected(selectedFeature, false);
@ -90,6 +102,8 @@ const J40Map = () => {
if (typeof window !== 'undefined' && window.Cypress && mapRef.current) {
window.underlyingMap = mapRef.current.getMap();
}
if (isMobile) setIsMobileMapState(true);
};
@ -110,6 +124,7 @@ const J40Map = () => {
});
};
const setMapSelected = (feature:MapboxGeoJSONFeature, isSelected:boolean) : void => {
// The below can be confirmed during debug with:
// mapRef.current.getFeatureState({"id":feature.id, "source":feature.source, "sourceLayer":feature.sourceLayer})
@ -125,6 +140,7 @@ const J40Map = () => {
}
};
const onClickTerritoryFocusButton = (event: MouseEvent<HTMLButtonElement>) => {
const buttonID = event.target && (event.target as HTMLElement).id;
@ -164,16 +180,15 @@ const J40Map = () => {
};
return (
<>
<div className={styles.mapAndInfoPanelContainer}>
<ReactMapGL
{...viewport}
className={styles.mapContainer}
mapStyle={makeMapStyle(flags)}
minZoom={constants.GLOBAL_MIN_ZOOM}
maxZoom={constants.GLOBAL_MAX_ZOOM}
mapOptions={{hash: true}}
width="100%"
height="52vw"
height={isMobileMapState ? '44vh' : '100%'}
dragRotate={false}
touchRotate={false}
interactiveLayerIds={[constants.HIGH_SCORE_LAYER_NAME]}
@ -183,8 +198,9 @@ const J40Map = () => {
onTransitionStart={onTransitionStart}
onTransitionEnd={onTransitionEnd}
ref={mapRef}
data-cy={'reactMapGL'}
>
{(detailViewData && !transitionInProgress) && (
{('fs' in flags && detailViewData && !transitionInProgress) && (
<Popup
className={styles.j40Popup}
tipSize={5}
@ -198,7 +214,6 @@ const J40Map = () => {
<AreaDetail properties={detailViewData.properties} />
</Popup>
)}
<NavigationControl
showCompass={false}
className={styles.navigationControl}
@ -214,7 +229,12 @@ const J40Map = () => {
<TerritoryFocusControl onClickTerritoryFocusButton={onClickTerritoryFocusButton}/>
{'fs' in flags ? <FullscreenControl className={styles.fullscreenControl}/> :'' }
</ReactMapGL>
</>
<MapInfoPanel
className={styles.mapInfoPanel}
featureProperties={detailViewData?.properties}
selectedFeatureId={selectedFeature?.id}
/>
</div>
);
};

View file

@ -0,0 +1,159 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rendering of the AreaDetail checks if various text fields are visible 1`] = `
<DocumentFragment>
<aside
data-cy="aside"
>
<header>
<div>
<div>
Cumulative Index Score
</div>
<div
data-cy="score"
>
9500.0
<sup>
<span>
th
</span>
</sup>
</div>
<div>
percentile
</div>
</div>
<div>
<div>
Categorization
</div>
<div>
<div />
<div>
Prioritized
</div>
</div>
</div>
</header>
<ul>
<li>
<span>
Census block group:
</span>
<span>
98729374234
</span>
</li>
<li>
<span>
County:
</span>
<span>
Washington County*
</span>
</li>
<li>
<span>
State:
</span>
<span>
District of Columbia*
</span>
</li>
<li>
<span>
Population:
</span>
<span>
3,435,435
</span>
</li>
</ul>
<div>
<div>
INDICATORS
</div>
<div>
PERCENTILE (0-100)
</div>
</div>
<li
data-cy="indicatorBox"
>
<div>
<div>
Poverty
</div>
<div>
Household income is less than or equal to twice the federal "poverty level"
</div>
</div>
<div>
9900.0
</div>
</li>
<li
data-cy="indicatorBox"
>
<div>
<div>
Education
</div>
<div>
Percent of people age 25 or older that didnt get a high school diploma
</div>
</div>
<div>
9800.0
</div>
</li>
<li
data-cy="indicatorBox"
>
<div>
<div>
Linguistic isolation
</div>
<div>
Households in which all members speak a non-English language and speak English less than "very well"
</div>
</div>
<div>
9700.0
</div>
</li>
<li
data-cy="indicatorBox"
>
<div>
<div>
Unemployment rate
</div>
<div>
Number of unemployed people as a percentage of the labor force
</div>
</div>
<div>
9600.0
</div>
</li>
<li
data-cy="indicatorBox"
>
<div>
<div>
Housing Burden
</div>
<div>
Households that are low income and spend more than 30% of their income to housing costs
</div>
</div>
<div>
9500.0
</div>
</li>
</aside>
,
</DocumentFragment>
`;

View file

@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`simulate a click on map hould match the snapshot of the MapInfoPanel component 1`] = `<DocumentFragment />`;
exports[`simulate app starting up, no click on map should match the snapshot of the MapIntroduction component 1`] = `
<DocumentFragment>
<div
class="someClassName"
>
<aside>
<header>
Zoom and select a census block group to view data
</header>
<div>
<img
src="test-file-stub"
/>
<div>
<div>
Did you know?
</div>
<cite>
A census block group is generally between 600 and 3,000 people. It is the smallest geographical unit for which the U.S. Census Bureau publishes sample data.
</cite>
</div>
</div>
</aside>
</div>
,
</DocumentFragment>
`;

View file

@ -1,23 +1,156 @@
.areaDetailTable {
max-width: 31.6vw;
@import "./areaDetailUtils.scss";
$sidePanelLabelFontColor: #171716;
$featureSelectBorderColor: #00bde3;
@mixin sidePanelLabelStyle {
font-size: small;
color: $sidePanelLabelFontColor;
font-weight: 600;
}
.titleContainer {
display: flex;
flex-direction: column;
padding: 22px 22px;
}
.titleIndicatorName {
font-weight: bold;
@mixin categorizationCircleStyle {
height: 0.6rem;
width: 0.6rem;
border-radius: 100%;
align-self: center;
margin-top: 0.8rem;
margin-right: 0.5rem;
opacity: 0.6;
}
.areaDetailContainer {
max-height: 50vh;
overflow: scroll;
display: flex;
flex-direction: column;
}
.areaDetailTableContainer {
overflow: auto;
padding: 22px;
// top row styles
.topRow {
display: flex;
}
.cumulativeIndexScore,
.categorization {
display: flex;
flex-direction: column;
align-items: center;
height: 7.7rem;
border-bottom: $sidePanelBorder;
flex: 1 0 50%;
padding-top: 2rem;
}
.topRowTitle,
.censusLabel {
@include sidePanelLabelStyle;
}
.topRowSubTitle {
font-size: small;
color: $sidePanelLabelFontColor;
}
.score {
font-size: xx-large;
font-weight: bolder;
}
.scoreSuperscript {
font-size: large;
padding-bottom: 1rem;
}
.categorization {
border-left: $sidePanelBorder;
}
.priority {
display: flex;
}
.prioritized {
@include categorizationCircleStyle;
background: #1a4480;
border: 1px solid $featureSelectBorderColor;
}
.threshold {
@include categorizationCircleStyle;
background: #d7dde7;
border: 1px solid $featureSelectBorderColor;
}
.nonPrioritized {
@include categorizationCircleStyle;
border: 1px solid $featureSelectBorderColor;
}
.prioritization {
font-size: large;
font-weight: bold;
padding-top: 0.8rem;
}
.censusRow {
display: flex;
flex-direction: column;
border-bottom: $sidePanelBorder;
list-style: none;
margin: 0;
}
//census row styles
.censusRow {
padding: 1rem;
}
.censusText,
.indicatorDescription {
font-size: small;
}
//Divider styles
.divider {
@include sidePanelLabelStyle;
display: flex;
justify-content: space-between;
border-bottom: $sidePanelBorder;
padding: 0.3rem 0.5rem 0.3rem 1rem;
background-color: #edeef0;
}
//Indicator box styles
.indicatorBox {
display: flex;
padding: 1.5rem 1rem;
border-bottom: $sidePanelBorder;
@media screen and (max-width: $mobileBreakpoint) {
justify-content: space-between;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
}
.indicatorBox:last-child {
border-bottom: none;
}
.indicatorTitle {
font-size: large;
font-weight: bolder;
}
.indicatorValue {
flex: 1 0 37%;
align-self: center;
padding-left: 2.4rem;
font-size: large;
@media screen and (max-width: $mobileBreakpoint) {
flex: 1 0 40%;
align-self: inherit;
padding-left: 3rem;
padding-top: 1rem;
}
}

View file

@ -1,10 +1,27 @@
declare namespace MapModuleScssNamespace {
export interface IMapModuleScss {
areaDetailContainer: string;
areaDetailTable:string;
areaDetailTableContainer:string;
titleContainer:string;
titleIndicatorName:string;
topRow:string;
cumulativeIndexScore:string;
scoreSuperscript: string;
topRowTitle:string;
topRowSubTitle:string;
categorization:string;
prioritized:string;
threshold:string;
nonPrioritized:string;
priority:string;
prioritization:string;
censusRow:string;
censusText: string;
censusLabel:string;
divider:string;
indicatorBox:string;
indicatorInfo:string;
indicatorTitle:string;
indicatorDescription:string;
indicatorValue:string;
score:string;
}
}

View file

@ -0,0 +1,47 @@
import * as React from 'react';
import {render} from '@testing-library/react';
import AreaDetail, {getCategorization, readablePercent} from './areaDetail';
import {LocalizedComponent} from '../test/testHelpers';
import * as constants from '../data/constants';
describe('rendering of the AreaDetail', () => {
const properties = {
[constants.POVERTY_PROPERTY_PERCENTILE]: 99,
[constants.EDUCATION_PROPERTY_PERCENTILE]: 98,
[constants.LINGUISTIC_ISOLATION_PROPERTY_PERCENTILE]: 97,
[constants.UNEMPLOYMENT_PROPERTY_PERCENTILE]: 96,
[constants.HOUSING_BURDEN_PROPERTY_PERCENTILE]: 95,
[constants.SCORE_PROPERTY_HIGH]: 95,
[constants.GEOID_PROPERTY]: 98729374234,
[constants.TOTAL_POPULATION]: 3435435,
};
const {asFragment} = render(
<LocalizedComponent>
<AreaDetail properties={properties}/>
</LocalizedComponent>,
)
;
it('checks if various text fields are visible', () => {
expect(asFragment()).toMatchSnapshot();
});
});
describe('tests the readablePercent function', () => {
expect(readablePercent(.9877665443)).toEqual('98.8');
});
describe('tests the getCategorization function', () => {
it(`should equal Prioritized for value >= ${constants.SCORE_BOUNDARY_LOW}`, () => {
expect(getCategorization(.756)).toEqual(['Prioritized', undefined]);
});
it(`should equal Threshold for .60 <= value < ${constants.SCORE_BOUNDARY_THRESHOLD}`, () => {
expect(getCategorization(.65)).toEqual(['Threshold', undefined]);
});
it(`should equal Non-prioritized for value < ${constants.SCORE_BOUNDARY_PRIORITIZED}`, () => {
expect(getCategorization(.53)).toEqual(['Non-prioritized', undefined]);
});
});

View file

@ -1,92 +1,210 @@
// External Libs:
import * as React from 'react';
import * as constants from '../data/constants';
import {useIntl} from 'gatsby-plugin-intl';
import {defineMessages} from 'react-intl';
// Components:
// Styles and constants
import * as styles from './areaDetail.module.scss';
import * as constants from '../data/constants';
export const readablePercent = (percent: number) => {
return `${(percent * 100).toFixed(1)}`;
};
export const getCategorization = (percentile: number) => {
let categorization;
let categoryCircleStyle;
if (percentile >= constants.SCORE_BOUNDARY_PRIORITIZED ) {
categorization = 'Prioritized';
categoryCircleStyle = styles.prioritized;
} else if (constants.SCORE_BOUNDARY_THRESHOLD <= percentile && percentile < constants.SCORE_BOUNDARY_PRIORITIZED) {
categorization = 'Threshold';
categoryCircleStyle = styles.threshold;
} else {
categorization = 'Non-prioritized';
categoryCircleStyle = styles.nonPrioritized;
}
return [categorization, categoryCircleStyle];
};
interface IAreaDetailProps {
properties: constants.J40Properties,
}
const AreaDetail = ({properties}:IAreaDetailProps) => {
const readablePercent = (percent: number) => {
return `${(percent * 100).toFixed(2)}`;
};
const intl = useIntl();
const messages = defineMessages({
cumulativeIndexScore: {
id: 'areaDetail.priorityInfo.cumulativeIndexScore',
defaultMessage: 'Cumulative Index Score',
description: 'the cumulative score of the feature selected',
},
percentile: {
id: 'areaDetail.priorityInfo.percentile',
defaultMessage: 'percentile',
description: 'the percentil of the feature selected',
},
categorization: {
id: 'areaDetail.priorityInfo.categorization',
defaultMessage: 'Categorization',
description: 'the categorization of prioritized, threshold or non-prioritized',
},
censusBlockGroup: {
id: 'areaDetail.geographicInfo.censusBlockGroup',
defaultMessage: 'Census block group:',
description: 'the census block group id number of the feature selected',
},
county: {
id: 'areaDetail.geographicInfo.county',
defaultMessage: 'County:',
description: 'the county of the feature selected',
},
state: {
id: 'areaDetail.geographicInfo.state',
defaultMessage: 'State: ',
description: 'the state of the feature selected',
},
population: {
id: 'areaDetail.geographicInfo.population',
defaultMessage: 'Population:',
description: 'the population of the feature selected',
},
indicatorColumnHeader: {
id: 'areaDetail.indicators.indicatorColumnHeader',
defaultMessage: 'INDICATORS',
description: 'the population of the feature selected',
},
percentileColumnHeader: {
id: 'areaDetail.indicators.percentileColumnHeader',
defaultMessage: 'PERCENTILE (0-100)',
description: 'the population of the feature selected',
},
poverty: {
id: 'areaDetail.indicator.poverty',
defaultMessage: 'Poverty',
description: 'Household income is less than or equal to twice the federal "poverty level"',
},
education: {
id: 'areaDetail.indicator.education',
defaultMessage: 'Education',
description: 'Percent of people age 25 or older that didnt get a high school diploma',
},
linguisticIsolation: {
id: 'areaDetail.indicator.linguisticIsolation',
defaultMessage: 'Linguistic isolation',
description: 'Households in which all members speak a non-English language and ' +
'speak English less than "very well"',
},
unemployment: {
id: 'areaDetail.indicator.unemployment',
defaultMessage: 'Unemployment rate',
description: 'Number of unemployed people as a percentage of the labor force',
},
houseBurden: {
id: 'areaDetail.indicator.houseBurden',
defaultMessage: 'Housing Burden',
description: 'Households that are low income and spend more than 30% of their income to housing costs',
},
});
const getCategorization = (percentile: number) => {
let categorization;
if (percentile >= 0.75 ) {
categorization = 'Prioritized';
} else if (0.60 <= percentile && percentile < 0.75) {
categorization = 'Threshold';
} else {
categorization = 'Non-prioritized';
}
return categorization;
};
const getTitleContent = () => {
const blockGroup = properties[constants.GEOID_PROPERTY];
const score = properties[constants.SCORE_PROPERTY_HIGH] as number;
return (
<div className={styles.titleContainer}>
<div>
<span className={styles.titleIndicatorName}>Census Block Group: </span>
<span>{blockGroup}</span>
</div>
<div>
<span className={styles.titleIndicatorName}>Just Progress Categorization: </span>
<span>{getCategorization(score)}</span>
</div>
<div>
<span className={styles.titleIndicatorName}>Cumulative Index Score: </span>
<span>{readablePercent(score)}</span>
</div>
</div>
);
const blockGroup = properties[constants.GEOID_PROPERTY];
const population = properties[constants.TOTAL_POPULATION];
interface indicatorInfo {
label: string,
description: string,
value: number,
}
// Todo: Ticket #367 will be replacing descriptions with YAML file
const povertyInfo:indicatorInfo = {
label: intl.formatMessage(messages.poverty),
description: 'Household income is less than or equal to twice the federal "poverty level"',
value: properties[constants.POVERTY_PROPERTY_PERCENTILE],
};
const eduInfo:indicatorInfo = {
label: intl.formatMessage(messages.education),
description: 'Percent of people age 25 or older that didnt get a high school diploma',
value: properties[constants.EDUCATION_PROPERTY_PERCENTILE],
};
const linIsoInfo:indicatorInfo = {
label: intl.formatMessage(messages.linguisticIsolation),
description: 'Households in which all members speak a non-English language and speak English less than "very well"',
value: properties[constants.LINGUISTIC_ISOLATION_PROPERTY_PERCENTILE],
};
const umemployInfo:indicatorInfo = {
label: intl.formatMessage(messages.unemployment),
description: 'Number of unemployed people as a percentage of the labor force',
value: properties[constants.UNEMPLOYMENT_PROPERTY_PERCENTILE],
};
const houseBurden:indicatorInfo = {
label: intl.formatMessage(messages.houseBurden),
description: 'Households that are low income and spend more than 30% of their income to housing costs',
value: properties[constants.HOUSING_BURDEN_PROPERTY_PERCENTILE],
};
const getBodyContent = () => {
const rows = [];
const sortedKeys = Object.entries(properties).sort();
for (let [key, value] of sortedKeys) {
// We should only format floats
if (typeof value === 'number' && value % 1 !== 0) {
value = readablePercent(value);
}
// Filter out all caps
if (!key.match(/^[A-Z0-9]+$/)) {
rows.push(<tr key={key} >
<td>{key}</td>
<td>{value}</td>
</tr>);
}
}
return rows;
};
const indicators = [povertyInfo, eduInfo, linIsoInfo, umemployInfo, houseBurden];
const [categorization, categoryCircleStyle] = getCategorization(score);
return (
<>
{properties ?
<div className={styles.areaDetailContainer}>
{getTitleContent()}
<div className={styles.areaDetailTableContainer}>
<table className={'usa-table usa-table--borderless ' + styles.areaDetailTable}>
<thead>
<tr>
<th scope="col">INDICATOR</th>
<th scope="col">VALUE</th>
</tr>
</thead>
<tbody>
{getBodyContent()}
</tbody>
</table>
<aside className={styles.areaDetailContainer} data-cy={'aside'}>
<header className={styles.topRow }>
<div className={styles.cumulativeIndexScore}>
<div className={styles.topRowTitle}>{intl.formatMessage(messages.cumulativeIndexScore)}</div>
<div className={styles.score} data-cy={'score'}>{`${readablePercent(score)}`}
<sup className={styles.scoreSuperscript}><span>th</span></sup>
</div>
</div> :
'' }
</>
<div className={styles.topRowSubTitle}>{intl.formatMessage(messages.percentile)}</div>
</div>
<div className={styles.categorization}>
<div className={styles.topRowTitle}>{intl.formatMessage(messages.categorization)}</div>
<div className={styles.priority}>
<div className={categoryCircleStyle} />
<div className={styles.prioritization}>{categorization}</div>
</div>
</div>
</header>
<ul className={styles.censusRow}>
<li>
<span className={styles.censusLabel}>{intl.formatMessage(messages.censusBlockGroup)} </span>
<span className={styles.censusText}>{blockGroup}</span>
</li>
<li>
<span className={styles.censusLabel}>{intl.formatMessage(messages.county)} </span>
<span className={styles.censusText}>{'Washington County*'}</span>
</li>
<li>
<span className={styles.censusLabel}>{intl.formatMessage(messages.state)}</span>
<span className={styles.censusText}>{'District of Columbia*'}</span>
</li>
<li>
<span className={styles.censusLabel}>{intl.formatMessage(messages.population)} </span>
<span className={styles.censusText}>{population.toLocaleString()}</span>
</li>
</ul>
<div className={styles.divider}>
<div>{intl.formatMessage(messages.indicatorColumnHeader)}</div>
<div>{intl.formatMessage(messages.percentileColumnHeader)}</div>
</div>
{indicators.map((indicator, index) => (
<li key={index} className={styles.indicatorBox} data-cy={'indicatorBox'}>
<div className={styles.indicatorInfo}>
<div className={styles.indicatorTitle}>{indicator.label}</div>
<div className={styles.indicatorDescription}>
{indicator.description}
</div>
</div>
<div className={styles.indicatorValue}>{readablePercent(indicator.value)}</div>
</li>
))}
</aside>
);
};

View file

@ -0,0 +1,10 @@
/*
This file is meant to hold styles that are shared with other SASS modules.
You can import this file into other SASS file to re-use styles.
Todo Design System: replace colors with tokens. Once these styles become more general the name can be less specific
*/
$sidePanelBorderColor: #f2f2f2;
$sidePanelBorder: 2px solid $sidePanelBorderColor;
$mobileBreakpoint: 400px;

View file

@ -0,0 +1,51 @@
import * as React from 'react';
import {render} from '@testing-library/react';
import MapInfoPanel from './mapInfoPanel';
import {LocalizedComponent} from '../test/testHelpers';
describe('simulate app starting up, no click on map', () => {
const {asFragment} = render(
<LocalizedComponent>
<MapInfoPanel
className={'someClassName'}
featureProperties={undefined}
selectedFeatureId={undefined}
/>
</LocalizedComponent>,
);
it('should match the snapshot of the MapIntroduction component', () => {
expect(asFragment()).toMatchSnapshot();
});
});
describe('simulate a click on map', () => {
const featureProperties = {
'GEOID10': '350459430003',
'Total population': 960,
'GEOID10 (percentile)': 0.5784380914343289,
'Housing burden (percent) (percentile)': 0.10073235017829946,
'Total population (percentile)': 0.2985685303608629,
'Linguistic isolation (percent) (percentile)': 0.9623469180109516,
'Percent of households in linguistic isolation (percentile)': 0.9230800651740774,
'Poverty (Less than 200% of federal poverty line) (percentile)': 0.947202643271775,
'Percent individuals age 25 or over with less than high school degree (percentile)': 0.7804232684164424,
'Unemployed civilians (percent) (percentile)': 0.9873599918675144,
'Score D (percentile)': 0.9321799276549586,
};
const selectedFeatureId = 345;
const {asFragment} = render(
<LocalizedComponent>
<MapInfoPanel
className={'J40Map-module--mapInfoPanel--8Ap7p'}
featureProperties={featureProperties}
selectedFeatureId={selectedFeatureId}
/>
</LocalizedComponent>,
);
it('hould match the snapshot of the MapInfoPanel component', () => {
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,22 @@
import React from 'react';
import MapIntroduction from './mapIntroduction';
import AreaDetail from './areaDetail';
interface IMapInfoPanelProps {
className: string,
featureProperties: { [key:string]: string | number } | undefined,
selectedFeatureId: string | number | undefined
}
const MapInfoPanel = ({className, featureProperties, selectedFeatureId}:IMapInfoPanelProps) => {
return (
<div className={className} >
{(featureProperties && selectedFeatureId ) ?
<AreaDetail properties={featureProperties} /> :
<MapIntroduction />
}
</div>
);
};
export default MapInfoPanel;

View file

@ -0,0 +1,34 @@
.mapIntroContainer {
padding: 20px 20px;
}
.mapIntroHeader {
font-size: xx-large;
line-height: 1.9rem;
padding-top: 0.8rem;
padding-left: 0.3rem;
}
.mapIntroText {
display: flex;
margin-top: 2.4rem;
}
.mapIntroLightbulb {
flex: 1 0 10%;
align-self: flex-start;
}
.didYouKnowBox {
padding-left: 0.4rem;
padding-top: 0.2rem;
font-size: large;
}
.didYouKnow {
font-weight: 600;
}
.didYouKnowText {
width: 95%;
padding-top: 0.3rem;
line-height: 1.5rem;
}

View file

@ -0,0 +1,18 @@
declare namespace MapIntroductionModuleScssNamespace {
export interface IMapIntroductionModuleScss {
mapIntroContainer: string;
mapIntroHeader: string;
mapIntroText: string;
mapIntroLightbulb: string;
didYouKnowBox: string
didYouKnow: string
didYouKnowText: string
}
}
declare const MapIntroductionModuleScssModule: MapIntroductionModuleScssNamespace.IMapIntroductionModuleScss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: MapIntroductionModuleScssNamespace.IMapIntroductionModuleScss;
};
export = MapIntroductionModuleScssModule;

View file

@ -0,0 +1,16 @@
import * as React from 'react';
import {render, screen} from '@testing-library/react';
import MapIntroduction from './mapIntroduction';
import {LocalizedComponent} from '../../src/test/testHelpers';
describe('rendering of the component', () => {
render(
<LocalizedComponent>
<MapIntroduction />
</LocalizedComponent>,
);
it('renders the title', () => {
expect(screen.getByRole('banner')).toHaveTextContent('Zoom and select a census block group to view data');
});
});

View file

@ -0,0 +1,45 @@
import React from 'react';
import {useIntl} from 'gatsby-plugin-intl';
import {defineMessages} from 'react-intl';
// @ts-ignore
import lightbulbIcon from '/node_modules/uswds/dist/img/usa-icons/lightbulb_outline.svg';
import * as styles from './mapIntroduction.module.scss';
const MapIntroduction = () => {
const intl = useIntl();
const messages = defineMessages({
mapIntroHeader: {
id: 'mapIntro.mapIntroHeader',
defaultMessage: 'Zoom and select a census block group to view data',
description: 'introductory text of ways to use the map',
},
didYouKnow: {
id: 'mapIntro.didYouKnow',
defaultMessage: ' Did you know?',
description: 'text prompting a cite paragraph',
},
censusBlockGroupDefinition: {
id: 'mapIntro.censusBlockGroupDefinition',
defaultMessage: 'A census block group is generally between 600 and 3,000 people. ' +
'It is the smallest geographical unit for which the U.S. Census ' +
'Bureau publishes sample data.',
description: 'cites the definition and helpful information about census groups',
},
});
return (
<aside className={styles.mapIntroContainer}>
<header className={styles.mapIntroHeader}>{intl.formatMessage(messages.mapIntroHeader)}</header>
<div className={styles.mapIntroText}>
<img className={styles.mapIntroLightbulb} src={lightbulbIcon} />
<div className={styles.didYouKnowBox}>
<div className={styles.didYouKnow}>{intl.formatMessage(messages.didYouKnow)}</div>
<cite className={styles.didYouKnowText}>{intl.formatMessage(messages.censusBlockGroupDefinition)}</cite>
</div>
</div>
</aside>
);
};
export default MapIntroduction;

View file

@ -1,13 +1,14 @@
import * as React from 'react';
import J40Map from './J40Map';
import MapLegend from '../components/mapLegend';
const MapWrapper = () => {
return (
<div>
{
<section>
<h2>Explore the Tool</h2>
<J40Map />
}
</div>
<MapLegend />
</section>
);
};

View file

@ -1,4 +1,5 @@
import {LngLatBoundsLike} from 'maplibre-gl';
import {isMobile as isMobileReactDeviceDetect} from 'react-device-detect';
// URLS
export const FEATURE_TILE_BASE_URL = 'https://d2zjid6n5ja2pt.cloudfront.net';
@ -22,13 +23,20 @@ export const HIGH_SCORE_LAYER_NAME = 'score-high-layer';
export const LOW_SCORE_SOURCE_NAME = 'score-low';
export const LOW_SCORE_LAYER_NAME = 'score-low-layer';
export const SELECTED_PROPERTY = 'selected';
export const POVERTY_PROPERTY_PERCENTILE = 'Poverty (Less than 200% of federal poverty line) (percentile)';
export const HOUSING_BURDEN_PROPERTY_PERCENTILE = 'Housing burden (percent) (percentile)';
export const LINGUISTIC_ISOLATION_PROPERTY_PERCENTILE = 'Linguistic isolation (percent) (percentile)';
export const UNEMPLOYMENT_PROPERTY_PERCENTILE = 'Unemployed civilians (percent) (percentile)';
export const TOTAL_POPULATION = 'Total population';
export const EDUCATION_PROPERTY_PERCENTILE = 'Percent individuals age 25 or over ' +
'with less than high school degree (percentile)';
// The name of the layer within the tiles that contains the score
export const SCORE_SOURCE_LAYER = 'blocks';
export type J40Properties = { [key: string]: any };
// Zoom
export const GLOBAL_MIN_ZOOM = 3;
export const GLOBAL_MAX_ZOOM = 22;
@ -100,4 +108,4 @@ export const SCORE_BOUNDARY_LOW = 0.0;
export const SCORE_BOUNDARY_THRESHOLD = 0.6;
export const SCORE_BOUNDARY_PRIORITIZED = 0.75;
export const isMobile = typeof window !== 'undefined' && (window.innerWidth < 400);
export const isMobile = isMobileReactDeviceDetect;

View file

@ -1,4 +1,60 @@
{
"areaDetail.geographicInfo.censusBlockGroup": {
"defaultMessage": "Census block group:",
"description": "the census block group id number of the feature selected"
},
"areaDetail.geographicInfo.county": {
"defaultMessage": "County:",
"description": "the county of the feature selected"
},
"areaDetail.geographicInfo.population": {
"defaultMessage": "Population:",
"description": "the population of the feature selected"
},
"areaDetail.geographicInfo.state": {
"defaultMessage": "State:",
"description": "the state of the feature selected"
},
"areaDetail.indicator.education": {
"defaultMessage": "Education",
"description": "Percent of people age 25 or older that didnt get a high school diploma"
},
"areaDetail.indicator.houseBurden": {
"defaultMessage": "Housing Burden",
"description": "Households that are low income and spend more than 30% of their income to housing costs"
},
"areaDetail.indicator.linguisticIsolation": {
"defaultMessage": "Linguistic Isolation",
"description": "Households in which all members speak a non-English language and speak English less than \"very well\""
},
"areaDetail.indicator.poverty": {
"defaultMessage": "Poverty",
"description": "Household income is less than or equal to twice the federal \"poverty level\""
},
"areaDetail.indicator.unemployment": {
"defaultMessage": "Unemployment",
"description": "Number of unemployed people as a percentage of the labor force"
},
"areaDetail.indicators.indicatorColumnHeader": {
"defaultMessage": "INDICATORS",
"description": "the population of the feature selected"
},
"areaDetail.indicators.percentileColumnHeader": {
"defaultMessage": "PERCENTILE (0-100)",
"description": "the population of the feature selected"
},
"areaDetail.priorityInfo.categorization": {
"defaultMessage": "Categorization",
"description": "the categorization of prioritized, threshold or non-prioritized"
},
"areaDetail.priorityInfo.cumulativeIndexScore": {
"defaultMessage": "Cumulative Index Score",
"description": "the cumulative score of the feature selected"
},
"areaDetail.priorityInfo.percentile": {
"defaultMessage": "percentile",
"description": "the percentil of the feature selected"
},
"areasOfInterest.climate": {
"defaultMessage": "Climate change",
"description": "item in areasOfInterest list"

View file

@ -2,7 +2,6 @@ import React from 'react';
import Layout from '../components/layout';
import MapWrapper from '../components/mapWrapper';
import HowYouCanHelp from '../components/HowYouCanHelp';
import MapLegend from '../components/mapLegend';
import DownloadPacket from '../components/downloadPacket';
import * as styles from './cejst.module.scss';
@ -52,10 +51,7 @@ const CEJSTPage = ({location}: IMapPageProps) => {
<DownloadPacket />
</div>
</section>
<h2>Explore the Tool</h2>
<MapWrapper />
<MapLegend />
<HowYouCanHelp />
</main>
</Layout>