mirror of
https://github.com/DOI-DO/j40-cejst-2.git
synced 2025-08-03 06:44:18 -07:00
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:
parent
a787bd71ab
commit
36f43b2d44
25 changed files with 1430 additions and 27185 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
159
client/src/components/__snapshots__/areaDetail.test.tsx.snap
Normal file
159
client/src/components/__snapshots__/areaDetail.test.tsx.snap
Normal 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 didn’t 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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
47
client/src/components/areaDetail.test.tsx
Normal file
47
client/src/components/areaDetail.test.tsx
Normal 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]);
|
||||
});
|
||||
});
|
|
@ -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 didn’t 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 score = properties[constants.SCORE_PROPERTY_HIGH] as number;
|
||||
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 didn’t 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 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 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 className={styles.topRowSubTitle}>{intl.formatMessage(messages.percentile)}</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
10
client/src/components/areaDetailUtils.scss
Normal file
10
client/src/components/areaDetailUtils.scss
Normal 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;
|
51
client/src/components/mapInfoPanel.test.tsx
Normal file
51
client/src/components/mapInfoPanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
22
client/src/components/mapInfoPanel.tsx
Normal file
22
client/src/components/mapInfoPanel.tsx
Normal 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;
|
34
client/src/components/mapIntroduction.module.scss
Normal file
34
client/src/components/mapIntroduction.module.scss
Normal 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;
|
||||
}
|
18
client/src/components/mapIntroduction.module.scss.d.ts
vendored
Normal file
18
client/src/components/mapIntroduction.module.scss.d.ts
vendored
Normal 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;
|
16
client/src/components/mapIntroduction.test.tsx
Normal file
16
client/src/components/mapIntroduction.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
45
client/src/components/mapIntroduction.tsx
Normal file
45
client/src/components/mapIntroduction.tsx
Normal 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;
|
|
@ -1,13 +1,14 @@
|
|||
import * as React from 'react';
|
||||
import J40Map from './J40Map';
|
||||
import MapLegend from '../components/mapLegend';
|
||||
|
||||
const MapWrapper = () => {
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
<J40Map />
|
||||
}
|
||||
</div>
|
||||
<section>
|
||||
<h2>Explore the Tool</h2>
|
||||
<J40Map />
|
||||
<MapLegend />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue