mirror of
https://github.com/DOI-DO/j40-cejst-2.git
synced 2025-08-03 05:54:19 -07:00
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:
parent
8b8314aeb3
commit
f12ab4d3b7
5 changed files with 712 additions and 464 deletions
872
client/package-lock.json
generated
872
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -2,4 +2,84 @@
|
||||||
height: 676px;
|
height: 676px;
|
||||||
margin-bottom: 29px;
|
margin-bottom: 29px;
|
||||||
max-width: revert;
|
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%);
|
||||||
}
|
}
|
||||||
|
|
6
client/src/components/map.module.scss.d.ts
vendored
6
client/src/components/map.module.scss.d.ts
vendored
|
@ -1,6 +1,12 @@
|
||||||
declare namespace MapModuleScssNamespace {
|
declare namespace MapModuleScssNamespace {
|
||||||
export interface IMapModuleScss {
|
export interface IMapModuleScss {
|
||||||
mapContainer: string;
|
mapContainer: string;
|
||||||
|
popupContainer: string;
|
||||||
|
popupCloser: string;
|
||||||
|
popupContent: string;
|
||||||
|
popupHeaderTable: string;
|
||||||
|
zoomWarning:string;
|
||||||
|
mapWrapperContainer:string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,17 +1,18 @@
|
||||||
import React, {useState, useEffect, useRef} from 'react';
|
import React, {useState, useEffect, useRef} from 'react';
|
||||||
import Map from 'ol/Map';
|
import Map from 'ol/Map';
|
||||||
import View from 'ol/View';
|
import View from 'ol/View';
|
||||||
import Feature from 'ol/Feature';
|
import Feature, {FeatureLike} from 'ol/Feature';
|
||||||
import Geometry from 'ol/geom/Geometry';
|
import Geometry from 'ol/geom/Geometry';
|
||||||
import VectorLayer from 'ol/layer/Vector';
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
import VectorSource from 'ol/source/Vector';
|
import VectorSource from 'ol/source/Vector';
|
||||||
import {fromLonLat} from 'ol/proj';
|
import {fromLonLat} from 'ol/proj';
|
||||||
import * as styles from './map.module.scss';
|
import * as styles from './map.module.scss';
|
||||||
import olms from 'ol-mapbox-style';
|
import olms from 'ol-mapbox-style';
|
||||||
|
import Overlay from 'ol/Overlay';
|
||||||
|
// import {Table} from '@trussworks/react-uswds';
|
||||||
|
|
||||||
interface IMapWrapperProps {
|
// @ts-ignore
|
||||||
features: Feature<Geometry>[],
|
import zoomIcon from '/node_modules/uswds/dist/img/usa-icons/zoom_in.svg';
|
||||||
};
|
|
||||||
|
|
||||||
const mapConfig = {
|
const mapConfig = {
|
||||||
'version': 8,
|
'version': 8,
|
||||||
|
@ -20,17 +21,27 @@ const mapConfig = {
|
||||||
'carto-light': {
|
'carto-light': {
|
||||||
'type': 'raster',
|
'type': 'raster',
|
||||||
'tiles': [
|
'tiles': [
|
||||||
'https://a.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_all/{z}/{x}/{y}@2x.png',
|
'https://b.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
|
||||||
'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
'https://c.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
|
||||||
'https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
'https://d.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'custom': {
|
'custom': {
|
||||||
'projection': 'EPSG:3857',
|
|
||||||
'type': 'vector',
|
'type': 'vector',
|
||||||
'tiles': [
|
'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',
|
'id': 'blocks',
|
||||||
'type': 'line',
|
'type': 'fill',
|
||||||
'source': 'custom',
|
'source': 'custom',
|
||||||
'source-layer': 'BlockGroup',
|
'source-layer': 'blocks',
|
||||||
'minzoom': 0,
|
'minzoom': 0,
|
||||||
'layout': {
|
'layout': {
|
||||||
'line-cap': 'round',
|
'line-cap': 'round',
|
||||||
'line-join': 'round',
|
'line-join': 'round',
|
||||||
},
|
},
|
||||||
|
// 01=AL, 30=MT, 34=NJ, 35=NM, 36=NY
|
||||||
|
'filter': ['in', 'STATEFP10', '01', '30', '34', '35', '36'],
|
||||||
'paint': {
|
'paint': {
|
||||||
'line-opacity': 0.6,
|
'fill-color': [
|
||||||
'line-color': 'red',
|
'interpolate',
|
||||||
'line-width': 1,
|
['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
|
// The below adapted from
|
||||||
// https://taylor.callsen.me/using-openlayers-with-react-functional-components/
|
// https://taylor.callsen.me/using-openlayers-with-react-functional-components/
|
||||||
const MapWrapper = ({features}: IMapWrapperProps) => {
|
const MapWrapper = ({features}: IMapWrapperProps) => {
|
||||||
const [map, setMap] = useState<Map>();
|
const [map, setMap] = useState<Map>();
|
||||||
const [featuresLayer, setFeaturesLayer] = useState<VectorLayer>();
|
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
|
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( () => {
|
useEffect( () => {
|
||||||
// create and add initial vector source layer, to be replaced layer
|
// create and add initial vector source layer, to be replaced layer
|
||||||
|
@ -78,6 +127,24 @@ const MapWrapper = ({features}: IMapWrapperProps) => {
|
||||||
source: new VectorSource(),
|
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({
|
const initialMap = new Map({
|
||||||
target: mapElement.current,
|
target: mapElement.current,
|
||||||
view: new View({
|
view: new View({
|
||||||
|
@ -85,9 +152,15 @@ const MapWrapper = ({features}: IMapWrapperProps) => {
|
||||||
zoom: 4,
|
zoom: 4,
|
||||||
}),
|
}),
|
||||||
controls: [],
|
controls: [],
|
||||||
|
overlays: [overlay],
|
||||||
});
|
});
|
||||||
|
const currentZoom = Math.floor(initialMap.getView().getZoom());
|
||||||
|
|
||||||
|
initialMap.on('moveend', handleMoveEnd);
|
||||||
|
initialMap.on('click', handleMapClick);
|
||||||
setMap(initialMap);
|
setMap(initialMap);
|
||||||
|
setCurrentZoom(currentZoom);
|
||||||
|
setCurrentOverlay(overlay);
|
||||||
setFeaturesLayer(initialFeaturesLayer);
|
setFeaturesLayer(initialFeaturesLayer);
|
||||||
olms(initialMap, mapConfig);
|
olms(initialMap, mapConfig);
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -112,8 +185,116 @@ const MapWrapper = ({features}: IMapWrapperProps) => {
|
||||||
}
|
}
|
||||||
}, [features]);
|
}, [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 (
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,6 @@ interface IMapPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CEJSTPage = ({location}: IMapPageProps) => {
|
const CEJSTPage = ({location}: IMapPageProps) => {
|
||||||
|
|
||||||
// We temporarily removed MapControls, which would enable you to `setFeatures` also, for now
|
// We temporarily removed MapControls, which would enable you to `setFeatures` also, for now
|
||||||
// We will bring back later when we have interactive controls.
|
// We will bring back later when we have interactive controls.
|
||||||
const [features] = useState<Feature<Geometry>[]>([]);
|
const [features] = useState<Feature<Geometry>[]>([]);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue