Issue 191 - Multi-state visualization (#226)

Addresses issue #191 - As a stakeholder interested in the cumulative impact score, I want to see more states in the map, so that I can further analyze the score results. Introduces gradient coloration on limited 5-state dataset as well as the core of a few key visual aspects of the map to be expanded upon later.
This commit is contained in:
Nat Hillard 2021-06-24 13:20:45 -04:00 committed by GitHub
commit f12ab4d3b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 712 additions and 464 deletions

872
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,4 +2,84 @@
height: 676px;
margin-bottom: 29px;
max-width: revert;
margin-top: 50px;
}
.popupContainer {
position: absolute;
background-color: white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
padding: 15px;
border-radius: 10px;
border: 1px solid #cccccc;
bottom: 12px;
left: -50px;
min-width: 280px;
}
.popupContainer:after,
.popupContainer:before {
top: 100%;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
}
.popupContainer:after {
border-top-color: white;
border-width: 10px;
left: 48px;
margin-left: -10px;
}
.popupContainer:before {
border-top-color: #cccccc;
border-width: 11px;
left: 48px;
margin-left: -11px;
}
.popupCloser {
text-decoration: none;
position: absolute;
top: 2px;
right: 8px;
}
.popupCloser:after {
content: "";
}
.popupContent {
max-height: 300px;
overflow: scroll;
}
.popupHeaderTable {
border-collapse: collapse;
border-spacing: 0;
font: normal 12px;
border: none;
}
.popupHeaderTable tbody td:first {
font-weight: bold;
}
.zoomWarning {
background-color: #953a10;
height: 8%;
width: 66%;
margin: auto;
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.zoomWarning > img {
filter: invert(100%);
}

View file

@ -1,6 +1,12 @@
declare namespace MapModuleScssNamespace {
export interface IMapModuleScss {
mapContainer: string;
popupContainer: string;
popupCloser: string;
popupContent: string;
popupHeaderTable: string;
zoomWarning:string;
mapWrapperContainer:string;
}
}

View file

@ -1,17 +1,18 @@
import React, {useState, useEffect, useRef} from 'react';
import Map from 'ol/Map';
import View from 'ol/View';
import Feature from 'ol/Feature';
import Feature, {FeatureLike} from 'ol/Feature';
import Geometry from 'ol/geom/Geometry';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import {fromLonLat} from 'ol/proj';
import * as styles from './map.module.scss';
import olms from 'ol-mapbox-style';
import Overlay from 'ol/Overlay';
// import {Table} from '@trussworks/react-uswds';
interface IMapWrapperProps {
features: Feature<Geometry>[],
};
// @ts-ignore
import zoomIcon from '/node_modules/uswds/dist/img/usa-icons/zoom_in.svg';
const mapConfig = {
'version': 8,
@ -20,17 +21,27 @@ const mapConfig = {
'carto-light': {
'type': 'raster',
'tiles': [
'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
'https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
'https://a.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
'https://b.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
'https://c.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
'https://d.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
],
},
'custom': {
'projection': 'EPSG:3857',
'type': 'vector',
'tiles': [
'https://gis.data.census.gov/arcgis/rest/services/Hosted/VT_2019_150_00_PY_D1/VectorTileServer/tile/{z}/{y}/{x}.mvt',
'http://usds-geoplatform-justice40-website.s3-website-us-east-1.amazonaws.com/0624_demo/{z}/{x}/{y}.pbf',
// For local development, use:
// 'http://localhost:8080/data/tl_2010_bg_with_data/{z}/{x}/{y}.pbf',
],
},
'labels': {
'type': 'raster',
'tiles': [
'https://cartodb-basemaps-a.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}@2x.png',
'https://cartodb-basemaps-b.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}@2x.png',
'https://cartodb-basemaps-c.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}@2x.png',
'https://cartodb-basemaps-d.global.ssl.fastly.net/light_only_labels/{z}/{x}/{y}@2x.png',
],
},
},
@ -44,33 +55,71 @@ const mapConfig = {
},
{
'id': 'blocks',
'type': 'line',
'type': 'fill',
'source': 'custom',
'source-layer': 'BlockGroup',
'source-layer': 'blocks',
'minzoom': 0,
'layout': {
'line-cap': 'round',
'line-join': 'round',
},
// 01=AL, 30=MT, 34=NJ, 35=NM, 36=NY
'filter': ['in', 'STATEFP10', '01', '30', '34', '35', '36'],
'paint': {
'line-opacity': 0.6,
'line-color': 'red',
'line-width': 1,
'fill-color': [
'interpolate',
['linear'],
['to-number', [
'get',
'Score C (percentile)',
]],
0.0,
'white',
1,
'blue',
],
'fill-opacity': 0.75,
},
},
{
'id': 'labels-only',
'type': 'raster',
'source': 'labels',
'minzoom': 0,
'maxzoom': 22,
},
],
};
interface IMapWrapperProps {
features: Feature<Geometry>[],
};
// The below adapted from
// https://taylor.callsen.me/using-openlayers-with-react-functional-components/
const MapWrapper = ({features}: IMapWrapperProps) => {
const [map, setMap] = useState<Map>();
const [featuresLayer, setFeaturesLayer] = useState<VectorLayer>();
const [currentOverlay, setCurrentOverlay] = useState<Overlay>();
const [selectedFeature, setSelectedFeature] = useState<FeatureLike>();
const [currentZoom, setCurrentZoom] = useState<number>(0);
const mapElement = useRef() as
React.MutableRefObject<HTMLInputElement>;
React.MutableRefObject<HTMLInputElement>;
// create state ref that can be accessed in OpenLayers onclick callback function
// https://stackoverflow.com/a/60643670
const mapRef = useRef() as React.MutableRefObject<Map>;
if (map) {
mapRef.current = map;
}
const popupContainer = React.useRef<HTMLDivElement>(null);
const popupCloser = React.useRef<HTMLAnchorElement>(null);
const popupContent = React.useRef<HTMLDivElement>(null);
const overlayRef = useRef() as React.MutableRefObject<Overlay>;
overlayRef.current = currentOverlay!;
useEffect( () => {
// create and add initial vector source layer, to be replaced layer
@ -78,6 +127,24 @@ const MapWrapper = ({features}: IMapWrapperProps) => {
source: new VectorSource(),
});
if (!popupCloser || !popupContainer) {
return;
}
popupCloser.current!.onclick = function() {
overlay.setPosition(undefined);
popupCloser.current!.blur();
return false;
};
const overlay = new Overlay({
// Using the non-null assertion operator as we check for null above
element: popupContainer.current!,
autoPan: true,
autoPanAnimation: {
duration: 250,
},
});
const initialMap = new Map({
target: mapElement.current,
view: new View({
@ -85,9 +152,15 @@ const MapWrapper = ({features}: IMapWrapperProps) => {
zoom: 4,
}),
controls: [],
overlays: [overlay],
});
const currentZoom = Math.floor(initialMap.getView().getZoom());
initialMap.on('moveend', handleMoveEnd);
initialMap.on('click', handleMapClick);
setMap(initialMap);
setCurrentZoom(currentZoom);
setCurrentOverlay(overlay);
setFeaturesLayer(initialFeaturesLayer);
olms(initialMap, mapConfig);
}, []);
@ -112,8 +185,116 @@ const MapWrapper = ({features}: IMapWrapperProps) => {
}
}, [features]);
const handleMapClick = (event: { pixel: any; }) => {
const clickedCoord = mapRef.current.getCoordinateFromPixel(event.pixel);
mapRef.current.forEachFeatureAtPixel(event.pixel, (feature) => {
setSelectedFeature(feature);
return true;
});
overlayRef.current.setPosition(clickedCoord);
};
const handleMoveEnd = () => {
const newZoom = Math.floor(mapRef.current.getView().getZoom()!);
if (currentZoom != newZoom) {
setCurrentZoom(newZoom);
}
};
const readablePercent = (percent: number) => {
return `${(percent * 100).toFixed(2)}%`;
};
let properties;
if (selectedFeature) {
properties = selectedFeature.getProperties();
}
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 = (properties: { [key: string]: any; }) => {
const blockGroup = properties['GEOID10'];
const score = properties['Score C (percentile)'];
return (
<table className={styles.popupHeaderTable}>
<tbody>
<tr>
<td><strong>Census Block Group:</strong></td>
<td>{blockGroup}</td>
</tr>
<tr>
<td><strong>Just Progress Categorization:</strong></td>
<td>{getCategorization(score)}</td>
</tr>
<tr>
<td><strong>Cumulative Index Score:</strong></td>
<td>{readablePercent(score)}</td>
</tr>
</tbody>
</table>
);
};
// const propertyTest = (propertyName:string) => {
// // Filter out properties in all caps
// return !propertyName.match(/^[A-Z0-9]+$/);
// };
return (
<div ref={mapElement} className={styles.mapContainer}></div>
<div className={styles.mapWrapperContainer}>
<div ref={mapElement} className={styles.mapContainer} />
<div ref={popupContainer} className={styles.popupContainer}>
<a href="#" ref={popupCloser} className={styles.popupCloser}></a>
<div ref={popupContent} className={styles.popupContent}>
{(selectedFeature && properties) ?
<div>
{getTitleContent(properties)}
{/* <Table bordered={false}>
<thead>
<tr>
<th scope="col">Indicator</th>
<th scope="col">Percentile(0-100)</th>
</tr>
</thead>
<tbody>
{
Object.keys(properties).map((key, index) => (
(propertyTest(key)) &&
<tr key={key} >
<td>{key}</td>
<td>{properties[key]}</td>
</tr>
))
}
</tbody>
</Table> */}
</div> :
''}
</div>
</div>
{ currentZoom < 5 ?
<div className={styles.zoomWarning}>
<img
src={zoomIcon} alt={'zoom icon'}/>
Zoom in to the state or regional level to see prioritized communities on the map.
</div> :
''
}
</div>
);
};

View file

@ -13,7 +13,6 @@ interface IMapPageProps {
}
const CEJSTPage = ({location}: IMapPageProps) => {
// We temporarily removed MapControls, which would enable you to `setFeatures` also, for now
// We will bring back later when we have interactive controls.
const [features] = useState<Feature<Geometry>[]>([]);